diff --git a/testnet/examples/expiry-and-recovery.md b/testnet/examples/expiry-and-recovery.md new file mode 100644 index 0000000..d1ee739 --- /dev/null +++ b/testnet/examples/expiry-and-recovery.md @@ -0,0 +1,363 @@ +# Worked Example: Letting an Account Expire and Verifying the Recovery Path + +## Overview + +This walkthrough takes an `EphemeralAccount` from creation with a short expiry, lets the ledger pass it, triggers the recovery path, and verifies what the `AccountExpired` event actually tells you. + +> **Read the "What the event does and does not prove" section before you rely on any of this for real funds.** The current MVP implementation performs the *state transition and event emission* for the recovery path but does **not** move SEP-41 tokens. This is the single most important thing to understand about expiry recovery today. + +The near-future-expiry technique that makes this testable in minutes rather than hours is in `testnet/config/expiry-ledger-testing.md`. + +--- + +## Preconditions + +- An `EphemeralAccount` WASM hash and the ability to deploy a fresh instance +- A **funded** `recovery_address` — it must exist on-ledger and hold XLM for rent, or you will not be able to receive or re-deploy anything with it +- A funded identity to submit transactions +- Contract IDs from `testnet/registry/contract-status.md` + +--- + +## Step 1: Create an Account With a ~5-Minute Expiry + +`expiry_ledger` is a **ledger sequence number**, not a timestamp. Testnet closes a ledger roughly every 5 seconds, so ~5 minutes is about 60 ledgers. + +```bash +# Read the live ledger +CURRENT_LEDGER=$(stellar network current-ledger \ + --rpc-url https://soroban-testnet.stellar.org) +echo "current ledger: $CURRENT_LEDGER" + +# ~5 minutes out +EXPIRY=$((CURRENT_LEDGER + 60)) +echo "expiry ledger: $EXPIRY" +``` + +Two rules govern the value: + +- `initialize()` rejects `expiry_ledger <= current_ledger` with `InvalidExpiry` (5). It must be **strictly** in the future. +- `is_expired()` returns `current_ledger >= expiry_ledger` — **greater than or equal**. So at exactly `expiry_ledger` the account is already expired. Do not plan to sweep in the final ledger. + +Deploy and initialize: + +```bash +stellar contract deploy \ + --wasm target/wasm32-unknown-unknown/release/ephemeral_account.wasm \ + --network testnet --source testnet-deployer + +stellar contract invoke \ + --id "$NEW_EPH_ID" \ + --network testnet --source testnet-deployer \ + -- initialize \ + --creator "$CREATOR" \ + --expiry_ledger "$EXPIRY" \ + --recovery_address "$RECOVERY" \ + --authorized_controller "$SWEEP_CONTROLLER_ID" \ + --admin "$ADMIN" +``` + +All six parameters are required and set exactly once — `initialize()` returns `AlreadyInitialized` (1) on any second call, and there is **no way to change `recovery_address` or `expiry_ledger` afterwards**. Get them right at creation. + +### The Recovery Address Is the Whole Point + +Everything in this document depends on one value chosen at `initialize()` time. If it is wrong, the account has no recovery path at all: + +| Mistake | Consequence | +|---|---| +| Points at an unfunded/nonexistent address | Funds have nowhere to go | +| Points at a key you do not control | Funds are unrecoverable by you | +| Points at a per-account throwaway | You must fund N addresses to support N accounts | +| Points at the ephemeral account itself | Funds stay exactly where they were | + +`recovery_address` is also **not** `swept_to`. Before a sweep or expiry, `swept_to` is `None`. After either, it holds whichever address received the routing. + +--- + +## Step 2: Fund It and Record a Payment + +```bash +# Actual token balance must exist on the ephemeral account +# (transfer the token in from a funded account first) + +stellar contract invoke \ + --id "$NEW_EPH_ID" --network testnet --source testnet-payer \ + -- record_payment --amount 100000000 --asset "$USDC_ASSET" +``` + +Confirm the pre-expiry state: + +```bash +stellar contract invoke --id "$NEW_EPH_ID" --network testnet \ + --source testnet-investigator -- is_expired +# → false + +stellar contract invoke --id "$NEW_EPH_ID" --network testnet \ + --source testnet-investigator -- get_status +# → 1 (PaymentReceived) +``` + +--- + +## Step 3: Wait for the Ledger to Pass + +Poll rather than sleeping a fixed amount — network conditions vary: + +```bash +while true; do + L=$(stellar network current-ledger --rpc-url https://soroban-testnet.stellar.org) + if [ "$L" -ge "$EXPIRY" ]; then echo "expired at ledger $L"; break; fi + echo "ledger $L / $EXPIRY ..."; sleep 10 +done +``` + +Then confirm the contract agrees: + +```bash +stellar contract invoke --id "$NEW_EPH_ID" --network testnet \ + --source testnet-investigator -- is_expired +# → true +``` + +There is no keeper job and no automatic action at the expiry ledger. **Expiry is a condition, not an event.** Nothing happens on-chain until someone calls a function. That is deliberate — `expire()` is permissionless precisely so funds cannot be stranded waiting for a keeper. + +--- + +## Step 4: Trigger the Recovery Path + +There are two entry points. They are equivalent in effect and both emit the same event. + +### `expire()` — permissionless + +Anyone may call it, including an identity that has no relationship to the account. No auth required. + +```bash +stellar contract invoke \ + --id "$NEW_EPH_ID" --network testnet --source testnet-investigator \ + -- expire +``` + +### `recover()` — creator or recovery address only + +```bash +stellar contract invoke \ + --id "$NEW_EPH_ID" --network testnet --source testnet-creator \ + -- recover --caller "$CREATOR" +``` + +`recover()` checks `caller == creator || caller == recovery_address` and then calls `caller.require_auth()`. So `--caller` must be an address whose secret the submitting identity holds, and a third party calling with someone else's address gets `Unauthorized` (8). + +**Use `expire()` unless you specifically need the auth-based variant.** Requiring auth here buys nothing and adds a failure mode. + +### Failure Modes + +| Call | Condition | Result | +|---|---|---| +| `expire()` | Before `expiry_ledger` | `NotExpired` (6) | +| `expire()` | Status already `Swept` or `Expired` | `InvalidStatus` (12) | +| `recover()` | `caller` is neither creator nor recovery address | `Unauthorized` (8) | +| `recover()` | Before `expiry_ledger` | `NotExpired` (6) | +| Either | Status already `Swept` or `Expired` | `InvalidStatus` (12) | +| Either | Never initialized | `NotInitialized` (2) | + +Recovery is **one-shot**. Once status is `Expired`, neither call can be repeated. + +--- + +## Step 5: Read the `AccountExpired` Event + +```bash +stellar contract events \ + --contract-id "$NEW_EPH_ID" \ + --start-ledger "$START_LEDGER" \ + --filter '{"topics": [["expired"]]}' \ + --network testnet +``` + +### Payload + +```rust +struct AccountExpired { + recovery_address: Address, // the configured recovery_address + amount_returned: i128, // sum of all recorded payment amounts + reserve_amount: i128, // reserve bookkeeping reclaimed in THIS call +} +``` + +Immediately after, a `ReserveReclaimed` event (topic `reserve`) follows, since `expire()` reclaims the tracked base reserve: + +```rust +struct ReserveReclaimed { + destination: Address, + amount: i128, + sweep_id: u64, // = the ledger sequence of the expire() call + fully_reclaimed: bool, + remaining_reserve: i128, +} +``` + +`sweep_id` on an expiry is the ledger in which `expire()` landed. That is the anchor for correlating the two events. + +### Decoding It + +```typescript +import { scValToNative } from '@stellar/stellar-sdk'; + +function decodeAccountExpired(data: unknown) { + const { recovery_address, amount_returned, reserve_amount } = + scValToNative(data) as any; + + return { + recoveryAddress: recovery_address as string, + // i128 decodes to a string. Keep BigInt; do not pass through Number(). + amountReturned: BigInt(amount_returned), + reserveAmount: BigInt(reserve_amount), + }; +} +``` + +Verify the address matches what you configured — this is the check that matters most: + +```typescript +if (decoded.recoveryAddress !== EXPECTED_RECOVERY) { + throw new Error(`recovery routing mismatch: ${decoded.recoveryAddress}`); +} +``` + +`amount_returned` is a **sum across assets**, and a multi-asset account makes it meaningless as a monetary figure. It is the arithmetic total of the recorded `Payment` amounts, not a verified transfer. See `testnet/examples/multi-asset-sweep.md`. + +--- + +## What the Event Does and Does Not Prove + +This is the critical section. + +### What it proves + +- The account was `Active`/`PaymentReceived` and is now `Expired`. +- The ledger had passed `expiry_ledger` when the call landed. +- The contract's `swept_to` is now set to `recovery_address`. +- The reserve bookkeeping was decremented and a `ReserveReclaimed` event was recorded. +- The one-shot recovery transition has been consumed. + +### What it does not prove + +**No SEP-41 tokens were moved.** `EphemeralAccount` contains no `TokenClient::transfer()` call anywhere. `expire()` and `recover()` set state and emit events; the reserve reclaim is likewise bookkeeping, not a transfer — `reclaim_reserve_to()` only updates counters and emits. The contract's own comment is explicit: *"Actual token transfers happen in the SDK via Stellar SDK. This contract enforces authorization/state transitions and reserve lifecycle."* + +So after a successful `expire()`: + +- The ephemeral account **still holds** the token balances it held before. +- The `recovery_address` balance is **unchanged**. +- `SweepCompleted` was **not** emitted — that event comes from `SweepController`, and only `execute_sweep()`/`claim()` emit it. + +Concretely: + +```bash +# The funds are still sitting on the ephemeral account +stellar contract invoke --id "$NEW_EPH_ID" --network testnet \ + --source testnet-investigator -- get_info +# status: 3 (Expired) +# swept_to: GAEG3... ← the recovery address, as a routing record +# payments: unchanged — recording is permanent history +``` + +Treat `AccountExpired` as *"the contract has marked this account as recovered-in-principle and recorded the intended destination"*, not as *"funds arrived"*. The only component that actually moves on-chain in the MVP is nothing. + +### Practical Consequences + +- **Do not reconcile balances off `AccountExpired`.** Verify balances directly at both addresses. +- **Do not treat expiry as a safety net for real funds.** In the MVP, real token movement happens only through `SweepController::execute_sweep()` / `::claim()`, and both refuse to run once an account is expired. An expired account is effectively a dead end for the sweep path. +- **Sweep before expiry.** Design your integration to sweep well inside the window, and use the recovery path as a diagnostic and accounting signal rather than a funds-recovery mechanism. + +A detailed checklist for confirming where balances actually ended up is in `testnet/runbooks/expired-account-recovery-check.md` (if it is not in your checkout yet, the verification steps are reproduced in Step 6 above). + +--- + +## Step 6: Verify End State + +```bash +stellar contract invoke --id "$NEW_EPH_ID" --network testnet \ + --source testnet-investigator -- get_status +# → 3 (Expired) + +stellar contract invoke --id "$NEW_EPH_ID" --network testnet \ + --source testnet-investigator -- get_info +# swept_to == recovery_address (routing record; not a balance transfer) + +# Reserve bookkeeping +stellar contract invoke --id "$NEW_EPH_ID" --network testnet \ + --source testnet-investigator -- get_reserve_remaining +# → 0 (1_000_000_000 stroops tracked and fully reclaimed) + +stellar contract invoke --id "$NEW_EPH_ID" --network testnet \ + --source testnet-investigator -- is_reserve_reclaimed +# → true + +stellar contract invoke --id "$NEW_EPH_ID" --network testnet \ + --source testnet-investigator -- get_last_reserve_event +# → ReserveReclaimed { destination: , amount: 1000000000, +# sweep_id: , fully_reclaimed: true, +# remaining_reserve: 0 } +``` + +`initialize()` seeds reserve tracking at `BASE_RESERVE_STROOPS = 1_000_000_000` stroops (1 XLM), and `expire()` reclaims it in the same call, so `reserve_amount` in `AccountExpired` is normally the full 1 XLM. This is a **counter**, not a balance — no XLM is transferred. + +### Repeated Reclaim Is Safe + +```bash +# Idempotent: returns 0 rather than erroring +stellar contract invoke --id "$NEW_EPH_ID" --network testnet \ + --source testnet-investigator -- reclaim_reserve +# → 0 +``` + +`reclaim_reserve()` is valid for any `Swept` or `Expired` account and transfers 0 once fully reclaimed. It still emits a `ReserveReclaimed` event with `amount: 0` and `fully_reclaimed: true`, so **event count is not a reliable proxy for "how much moved"** — filter on `amount`, not on cardinality. + +--- + +## Post-Expiry: What No Longer Works + +| Operation | Result | +|---|---| +| `sweep()` via controller | `AccountExpired` (11) | +| `claim()` via controller | Fails — `sweep_claim()` hits the same expiry guard | +| `expire()` / `recover()` again | `InvalidStatus` (12) | +| `record_payment()` | **Still succeeds** — it has no status or expiry guard | +| `simulate_sweep()` | Returns `(empty, 11)` | +| `can_sweep()` | `false` | + +`record_payment()` accepting new payments on an expired account is a real gap: entries can be appended to an account that can no longer be swept. If you are monitoring an account you care about, gate on `is_expired()` before recording, in your own code. + +--- + +## Common Pitfalls + +| Pitfall | Symptom | Fix | +|---|---|---| +| Passing a Unix timestamp as `expiry_ledger` | `InvalidExpiry`, or instant expiry | Ledger sequence only | +| Off-by-one at the boundary | Sweep works, then suddenly `AccountExpired` | `is_expired()` is `>=`; leave a wide margin | +| Assuming funds moved | `AccountExpired` seen, balance unchanged | Expected in the MVP — see the section above | +| Unfunded `recovery_address` | Nothing to receive, nothing to re-deploy with | Fund it before creating accounts | +| Sleeping a fixed duration | Test flakes as ledger timing drifts | Poll `is_expired()` | +| Expecting an automatic event at expiry | No events until you call something | `expire()` is permissionless and manual | +| `swept_to` read as "funds went here" | Wrong conclusion | It is a routing record only | + +--- + +## Related Documentation + +- `testnet/config/expiry-ledger-testing.md` — near-future expiry computation +- `testnet/runbooks/expired-account-recovery-check.md` — verifying where funds actually ended up +- `testnet/runbooks/stuck-ephemeral-account.md` — accounts that will not expire or sweep +- `testnet/registry/event-topics.md` — `expired` and `reserve` topic encoding +- `testnet/registry/known-test-accounts.md` — long-lived fixtures with far-future expiry +- `docs/api-reference.md` — `expire`, `recover`, `reclaim_reserve` references +- `docs/security.md` — expiration threat model + +--- + +## Version History + +| Version | Date | Changes | +|---|---|---| +| 1.0 | 2026-09-26 | Initial expiry and recovery walkthrough | diff --git a/testnet/examples/gas-free-claim-walkthrough.md b/testnet/examples/gas-free-claim-walkthrough.md new file mode 100644 index 0000000..d06c953 --- /dev/null +++ b/testnet/examples/gas-free-claim-walkthrough.md @@ -0,0 +1,266 @@ +# Worked Example: Gas-Free `claim()` — Recipient Signs, Relayer Submits + +## Overview + +`SweepController::claim()` is architecturally different from `execute_sweep()`, and that difference is the whole reason it exists: + +| | `execute_sweep()` | `claim()` | +|---|---|---| +| **Who authorizes** | A signing key, via a 64-byte Ed25519 signature over a custom digest | The **recipient**, via a native Soroban auth entry | +| **Auth mechanism** | `env.crypto().ed25519_verify()` against `authorized_signer` | `recipient.require_auth()` on the contract call itself | +| **Custom digest to construct** | Yes — `SHA256(dest ‖ nonce ‖ contract_id)` | **No** — the network handles it | +| **Fee payer** | Relayer | Relayer | +| **Consumes the nonce** | Yes | Yes | + +The upshot for integrators: **there is no off-chain signing service and no message-construction step.** The recipient signs a standard Soroban auth entry, and any funded third party submits the transaction and eats the fee. The recipient never needs XLM of their own. + +If you already have a working signed-sweep flow, you probably do not need this. If you do not want to run an Ed25519 signer at all, this is the path. + +> Configuration-side setup for this path — identity setup, locked vs. flexible mode, expected errors — is covered in `testnet/config/gas-free-claim-testing.md`. This document is the worked end-to-end example, including how the auth entry is actually built and signed. + +--- + +## How the Authorization Actually Works + +This is the part that costs integrators the most trial-and-error, so it is worth being precise. + +`claim()` does three things: + +1. `recipient.require_auth()` — the recipient authorizes *this exact contract call*. +2. `validate_destination(&recipient)` — locked-mode check, if the controller has one. +3. `authorize_claim()` — builds an `InvokerContractAuthEntry` for a **sub-contract invocation** of `sweep_claim` on the `EphemeralAccount`, and satisfies it with `env.authorize_as_current_contract()`. + +Step 3 is the clever part. `EphemeralAccount::sweep_claim()` requires its `authorized_controller` to authorize. `SweepController` *is* that controller, and a contract can authorize itself as the invoker. So: + +``` +recipient --Soroban auth entry--> SweepController::claim() + | + | authorize_as_current_contract() + v + EphemeralAccount::sweep_claim(destination) +``` + +The recipient's signature therefore transitively satisfies the ephemeral account's controller check. **The recipient's auth entry is what proves they authorized this** — there is no separate signature check anywhere in this path. + +> `sweep_claim` is invoked under the host-symbol name `swp_claim` (9 characters, the `symbol_short!` limit). If you are constructing the sub-invocation context yourself in a host environment or an unusual SDK, `swp_claim` is what appears on the wire, not `sweep_claim`. + +--- + +## Prerequisites + +Two funded testnet identities: + +| Identity | Role | +|---|---| +| `testnet-recipient` | Signs the auth entry. Receives the funds. | +| `testnet-relayer` | Submits the transaction. Pays all fees. | + +```bash +stellar keys generate --global testnet-recipient +stellar keys fund testnet-recipient --network testnet + +stellar keys generate --global testnet-relayer +stellar keys fund testnet-relayer --network testnet + +RECIPIENT=$(stellar keys address testnet-recipient) +RELAYER=$(stellar keys address testnet-relayer) +echo "recipient=$RECIPIENT relayer=$RELAYER" +``` + +Plus an `EphemeralAccount` that is initialized, has a payment recorded, and has not expired. The controller must be flexible mode (`authorized_destination = None`) unless the recipient *is* the locked destination. + +--- + +## Step 1: Build the Auth Entry + +A Soroban auth entry is not a free-form signature. It names the contract, the function, and the arguments — and the host binds the signature to exactly those. Change any argument and the signature no longer applies. + +For `claim(recipient, ephemeral_account)` the entry is conceptually: + +```json +{ + "credentials": { + "address": "", + "invocation": { + "function": "claim", + "subinvocations": [] + } + }, + "root_invocation": { + "function": "claim", + "subinvocations": [] + } +} +``` + +Concretely, the `SorobanAuthorizationEntry` the recipient signs covers: + +- **Credentials address** — the recipient's `G...` address. This is what `require_auth()` matches against. +- **Root invocation** — contract `CBEU...` (the `SweepController`), function `claim`, arguments `[recipient, ephemeral_account]`, no sub-invocations. +- **Signature** — over the SHA-256 of the canonical `SorobanAuthorizationEntry` XDR, with the account's own domain-separated scheme. **This is account-key signing, not the sweep digest**, and the two are not interchangeable. + +### Building It in an SDK + +In the JS SDK the entry is assembled alongside the transaction and the account signs the operation: + +```typescript +import { SorobanRpc, Contract, Networks, TransactionBuilder, Operation } from '@stellar/stellar-sdk'; + +const tx = new TransactionBuilder(account, { fee: '100', networkPassphrase: Networks.TESTNET }) + .addOperation(new Operation.Contract({ contract, source: new Account(ephemeralId, '0') })) + .setTimeout(300) + .build(); + +// The account signs the operation; Soroban auth entries are derived and +// attached from that signature. No custom digest is constructed here. +await tx.sign(recipientKeypair).sign(relayerKeypair); +``` + +The recipient signature comes from `tx.sign(recipientKeypair)`; the relayer signature covers the envelope and pays the fee. Both signatures end up in one envelope — that is what makes the flow "gas-free" for the recipient. + +### What `stellar contract invoke` Does For You + +The CLI handles the entry construction, so for a manual test you do not build any of the above: + +```bash +stellar contract invoke \ + --id "$SWEEP_CONTROLLER_ID" \ + --network testnet \ + --source testnet-relayer \ + -- \ + claim \ + --recipient "$RECIPIENT" \ + --ephemeral_account "$EPHEMERAL_ID" +``` + +**`--source` is the relayer**, and that is the entire trick. The CLI attaches a `SorobanCredentials::Address` auth entry naming `$RECIPIENT` for the `claim` invocation, and — because the recipient's secret is not available to the CLI — this succeeds only if the recipient has separately signed that entry, or if you are invoking with the recipient's own key available to the CLI. + +In practice, for a manual walkthrough the reliable shapes are: + +- **Recipient signs offline, relayer submits** — build the envelope in an SDK, have the recipient sign the operation, then broadcast from the relayer. This is the real production shape. +- **Recipient is also the fee payer** — `--source testnet-recipient` and the CLI supplies everything. Simple, but it defeats the purpose (the recipient pays fees, so they need XLM). + +> **Do not pass `--source testnet-recipient` and expect the relayer to be free.** The `--source` identity pays the fee. The "gas-free" property is about *who signs the authorization*, not about fees disappearing — a relayer always pays, and someone must fund it. + +--- + +## Step 2: Submit + +Envelope assembled and both signatures attached: + +```typescript +const result = await server.sendTransaction(tx); +``` + +Or, purely for a manual smoke test where the recipient's key is available to the CLI: + +```bash +# Relayer-funded submission of a pre-signed envelope +stellar contract invoke \ + --id "$SWEEP_CONTROLLER_ID" \ + --network testnet \ + --source testnet-relayer \ + -- \ + claim \ + --recipient "$RECIPIENT" \ + --ephemeral_account "$EPHEMERAL_ID" +``` + +On success the controller emits `SweepCompleted { ephemeral_account, destination: recipient, amount }` and the ephemeral account emits `SweepExecutedMulti` plus a `ReserveReclaimed`. + +--- + +## Step 3: Verify + +```bash +# Status is Swept +stellar contract invoke --id "$EPHEMERAL_ID" --network testnet \ + --source testnet-investigator -- get_status +# → 2 (Swept) + +# Nonce advanced by exactly 1 +BEFORE=$(stellar contract invoke --id "$SWEEP_CONTROLLER_ID" --network testnet \ + --source testnet-investigator -- get_nonce) +# ... claim ... +AFTER=$(stellar contract invoke --id "$SWEEP_CONTROLLER_ID" --network testnet \ + --source testnet-investigator -- get_nonce) +echo "$BEFORE -> $AFTER" # expect 7 -> 8 + +# Recipient balance increased +``` + +The nonce increment matters more here than it looks: `claim()` and `execute_sweep()` draw from the **same** counter. A `claim()` will invalidate a signature you are concurrently holding for `execute_sweep()`. This is the same shared-nonce hazard described in `testnet/config/nonce-tracking.md`. + +--- + +## Locked Mode + +If the controller was initialized with `authorized_destination`, only that address may claim: + +```bash +# Succeeds — recipient IS the locked destination +stellar contract invoke --id "$SWEEP_CONTROLLER_ID" --network testnet \ + --source testnet-relayer -- claim \ + --recipient "$LOCKED_DESTINATION" --ephemeral_account "$EPHEMERAL_ID" + +# Fails with UnauthorizedDestination +stellar contract invoke --id "$SWEEP_CONTROLLER_ID" --network testnet \ + --source testnet-relayer -- claim \ + --recipient "$SOMEONE_ELSE" --ephemeral_account "$EPHEMERAL_ID_2" +# → Error::UnauthorizedDestination (13) +``` + +This is a meaningful security difference from the signed path: **the recipient's own signature cannot override locked mode.** The destination check runs after `require_auth()` and is not signer-dependent. In flexible mode, whoever signs the auth entry chooses the destination, so the recipient address *is* the destination — there is no separate "destination" parameter to disagree with. + +--- + +## Failure Modes + +| Symptom | Cause | Fix | +|---|---|---| +| Auth failure, no specific error | Recipient did not sign the `claim` invocation | Ensure the auth entry names `claim` with args `[recipient, ephemeral_account]` | +| Auth failure, signature rejected | Entry was signed but an argument differs | Every argument is bound; `recipient` and `ephemeral_account` must match exactly | +| `UnauthorizedDestination` | Locked mode, recipient ≠ locked destination | Use the locked address, or change it while nonce is still 0 | +| `AccountNotReady` | No payment recorded, or summed amount is 0 | `record_payment()` first | +| `AccountExpired` | Past `expiry_ledger` | Expired accounts go to `recovery_address`; see `testnet/examples/expiry-and-recovery.md` | +| Trap / host error | `get_info()` on an uninitialized or non-contract address | Check the ephemeral account ID | +| Rejected at submission | Relayer unfunded | `stellar keys fund testnet-relayer --network testnet` | + +The most confusing case is the first two, because a missing or mismatched auth entry surfaces as a bare auth failure rather than a named error. If `claim()` fails auth, compare the signed entry's contract, function name, and arguments against the invocation you are actually submitting before suspecting the contracts. + +--- + +## Choosing Between This and `execute_sweep()` + +Use `claim()` when: + +- The recipient is a real user who can sign a transaction but should not hold XLM. +- You are building a relayer and want recipients to be self-authorizing. +- You have no off-chain Ed25519 signing infrastructure. + +Use `execute_sweep()` when: + +- The authorization decision is made **server-side** (a backend decides the destination; the user has no key in the flow at all). +- You need the destination to be enforced independently of who signs. +- You need a signature that survives independently of the submitting transaction. + +The signed path is the more common integration. `claim()` is a genuinely different shape, not a cheaper variant. + +--- + +## Related Documentation + +- `testnet/config/gas-free-claim-testing.md` — configuration and scenario matrix for the claim path +- `testnet/config/nonce-tracking.md` — the shared nonce `claim()` also consumes +- `testnet/examples/signed-sweep-walkthrough.md` — the signed-sweep alternative, end to end +- `testnet/examples/multi-asset-sweep.md` — multi-asset sweep behaviour +- `docs/api-reference.md` — `claim()` API reference +- `docs/architecture.md` — cross-contract call graph + +--- + +## Version History + +| Version | Date | Changes | +|---|---|---| +| 1.0 | 2026-09-26 | Initial claim() walkthrough | diff --git a/testnet/examples/multi-asset-sweep.md b/testnet/examples/multi-asset-sweep.md new file mode 100644 index 0000000..39f1d95 --- /dev/null +++ b/testnet/examples/multi-asset-sweep.md @@ -0,0 +1,307 @@ +# Worked Example: Multi-Asset Sweep via `SweepController::execute_sweep` + +## Overview + +An `EphemeralAccount` can record **one payment per SEP-41 asset** — up to 10 distinct assets. A sweep then moves every recorded payment in a single transaction. This walkthrough exercises that multi-asset path end to end, and covers the part that reliably confuses integrators: **how to read the resulting `SweepExecutedMulti` event's payment list**, and which of the two sweep events to believe. + +> The test tokens and amounts to use are catalogued in `testnet/config/multi-asset-testing.md`. If that document is not yet present in your checkout, the placeholders below are enough to run the flow with any two SEP-41 tokens on testnet. + +--- + +## The Rules That Shape This Flow + +Three constraints interact here, and all of them are enforced in `EphemeralAccount::record_payment()`: + +| Rule | Limit | Error | +|---|---|---| +| One payment per asset | Second call for the same `asset` address is rejected | `DuplicateAsset` (13) | +| Hard cap on distinct assets | 11th distinct asset rejected | `TooManyPayments` (14) | +| Amount must be positive | `amount <= 0` rejected | `InvalidAmount` (4) | + +"Multi-asset" means one payment **per asset**, not multiple payments of the same asset. If you need to accumulate several payments of one token, that is not what this design does — use one `EphemeralAccount` per payment. Full behaviour table: `testnet/runbooks/multi-payment-edge-cases.md`. + +The duplicate check runs before the amount check, so re-submitting a bad amount for an already-recorded asset still returns `DuplicateAsset`. + +--- + +## Step 1: Fund and Record Two Assets + +Get two SEP-41 token contract IDs and the account's address: + +```bash +EPHEMERAL_ID= +XLM_ASSET=CA3D5KRYM6CB7OWQ6TWYRRULZAT3TCJ2Q6Q7HGFAXUVK2QMRMDQX3T5Y +USDC_ASSET= +``` + +The ephemeral account itself must hold a balance of each token for the transfer to succeed. Recording a payment does not move anything — it is a bookkeeping entry that the sweep later acts on. If the account has no balance, the sweep reaches step 4 and then fails with `TransferFailed`. + +```bash +# First asset → PaymentReceived (topic "payment"), status flips Active → PaymentReceived +stellar contract invoke \ + --id "$EPHEMERAL_ID" --network testnet --source testnet-payer \ + -- record_payment --amount 100000000 --asset "$USDC_ASSET" + +# Second asset → MultiPaymentReceived (topic "multi_pay"), status unchanged +stellar contract invoke \ + --id "$EPHEMERAL_ID" --network testnet --source testnet-payer \ + -- record_payment --amount 2500000000 --asset "$XLM_ASSET" +``` + +Note the asymmetry in event names: the **first** payment emits `PaymentReceived`, every subsequent one emits `MultiPaymentReceived`. An indexer that only watches `payment` will miss every asset after the first. Full topic list: `testnet/registry/event-topics.md`. + +### Confirm What Was Recorded + +```bash +stellar contract invoke \ + --id "$EPHEMERAL_ID" --network testnet --source testnet-investigator \ + -- get_info +# payment_received: true +# payment_count: 2 +# payments: [ { asset: USDC, amount: 100000000, timestamp: ... }, +# { asset: XLM, amount: 2500000000, timestamp: ... } ] + +# Cheaper alternative when you only need the count +stellar contract invoke \ + --id "$EPHEMERAL_ID" --network testnet --source testnet-investigator \ + -- get_payment_count +# → 2 +``` + +`get_info()` returns every payment unpaginated. `get_info_paginated()` is the bounded variant — it clamps `limit` to 1..100 and uses `NO_CURSOR` (`4294967295`) for "first page": + +```bash +stellar contract invoke \ + --id "$EPHEMERAL_ID" --network testnet --source testnet-investigator \ + -- get_info_paginated \ + --params '{"limit": 50, "cursor_index": 4294967295}' +``` + +With a 10-asset cap, pagination will never matter in practice — but the paginated path is the one to prefer in a client that might be pointed at a future contract with a higher cap. + +--- + +## Step 2: Check Readiness + +```bash +stellar contract invoke \ + --id "$SWEEP_CONTROLLER_ID" --network testnet --source testnet-investigator \ + -- can_sweep --ephemeral_account "$EPHEMERAL_ID" +# → true + +# See exactly what would move, and whether anything blocks it +stellar contract invoke \ + --id "$EPHEMERAL_ID" --network testnet --source testnet-investigator \ + -- simulate_sweep --destination "$DESTINATION" +# → (payments, error_code) error_code == 0 means unblocked +``` + +`can_sweep()` requires `payment_received == true` **and** status exactly `PaymentReceived` **and** not expired. Note it is a strict equality on status, so it returns `false` for an account that has already been swept or expired even though a sweep would also fail there. It is a readiness check, not an error code. + +--- + +## Step 3: Sign and Sweep + +Identical mechanics to the single-asset case — the digest is `SHA256(destination ‖ nonce ‖ controller_id)` and knows nothing about assets or amounts. One signature authorises a sweep of **whatever is currently recorded**, including assets recorded after the signature was produced. + +That is worth stating plainly: **the signed message does not commit to the payment set.** A signature obtained at nonce 7 authorises a sweep of any account state reachable at nonce 7. Do not treat a signed sweep as authorisation of a specific amount or asset list. Full detail: `testnet/examples/signed-sweep-walkthrough.md`. + +```bash +NONCE=$(stellar contract invoke \ + --id "$SWEEP_CONTROLLER_ID" --network testnet --source testnet-investigator \ + -- get_nonce) + +SIG=$(cargo run --quiet --manifest-path tools/sweep-signer/Cargo.toml -- sign \ + --contract-id "$SWEEP_CONTROLLER_ID" \ + --destination "$DESTINATION" \ + --nonce "$NONCE" \ + --signer-seed-hex "$SEED" \ + | grep '^auth_signature' | awk '{print $NF}') + +stellar contract invoke \ + --id "$SWEEP_CONTROLLER_ID" --network testnet --source testnet-relayer \ + -- execute_sweep \ + --ephemeral_account "$EPHEMERAL_ID" \ + --destination "$DESTINATION" \ + --auth_signature "$SIG" +``` + +### Atomicity + +`transfers::execute_transfers()` loops over the payments calling SEP-41 `TokenClient::transfer()` for each. Soroban transactions are atomic, so if the third of four transfers fails, the whole transaction reverts — no partial sweep, no partially-drained account. The observable effect of a failed multi-asset sweep is **total failure**, not partial movement. + +The practical consequence: adding a low-balance asset to an account makes the *entire* sweep fail, including the well-funded ones. There is no "best effort" mode. + +--- + +## Step 4: Read `SweepExecutedMulti.payments` + +Two events fire, on **different contracts**, and they do not contain the same information. + +| Event | Contract | Payload | Contains per-asset detail? | +|---|---|---|---| +| `SweepExecutedMulti` | `EphemeralAccount` | `destination`, `payments: Vec` | **Yes** | +| `SweepCompleted` | `SweepController` | `ephemeral_account`, `destination`, `amount: i128` | **No** | + +`swept_mul` is the one to read for multi-asset accounting. `sweep` carries only a summed `i128`, which across different assets is not a meaningful figure — never render it to a user as "amount swept." + +```rust +struct Payment { + asset: Address, // which token + amount: i128, // in that token's base units + timestamp: u64, // ledger timestamp at record_payment, not sweep time +} +``` + +### Fetching It + +```bash +stellar contract events \ + --contract-id "$EPHEMERAL_ID" \ + --start-ledger "$START_LEDGER" \ + --filter '{"topics": [["swept_mul"]]}' \ + --network testnet +``` + +Or via RPC, scoped to the sweep ledger: + +```bash +curl -X POST https://soroban-testnet.stellar.org \ + -H "Content-Type: application/json" \ + -d '{ + "jsonrpc": "2.0", "id": 1, "method": "getEvents", + "params": { + "startLedger": '"$START_LEDGER"', + "endLedger": '"$END_LEDGER"', + "filters": [{ + "type": "contract", + "contractIds": ["'"$EPHEMERAL_ID"'"], + "topics": [["swept_mul"]] + }] + } + }' +``` + +### Decoding the Payment List + +```typescript +import { Horizon, scValToNative, StrKey, Contract } from '@stellar/stellar-sdk'; + +interface DecodedPayment { asset: string; assetType: string; amount: string; timestamp: number; } + +function decodeSweepExecutedMulti(data: unknown, networkPassphrase: string): { + destination: string; + payments: DecodedPayment[]; +} { + const value = scValToNative(data) as any; + const { destination, payments } = value; + + return { + destination, + payments: payments.map((p: any) => ({ + asset: p.asset, + // asset is itself a contract address -> resolve to a human symbol + assetType: 'contract', + // i128 arrives as a string; never coerce through Number. + amount: BigInt(p.amount).toString(), + timestamp: Number(p.timestamp), + })), + }; +} +``` + +Three decoding traps: + +1. **`i128` fields decode to strings, not numbers.** `amount` above 2^53 loses precision through `Number()`. Keep it as `BigInt` and format at the edge. The same applies to `SweepCompleted.amount` and `AccountExpired.total_amount`. +2. **`asset` is an `Address`, not a ticker.** You get a `C...` contract ID. Resolving it to `USDC` requires a SEP-41 `name()` call or your own token registry — the event does not carry a symbol. +3. **`timestamp` is when the payment was recorded, not when the sweep ran.** The field is copied from the stored `Payment`. Do not use it as the sweep time. + +### Resolving Asset Symbols + +```typescript +async function assetSymbol(contractId: string, networkPassphrase: string): Promise { + const client = new Contract(contractId); + try { + return await client.name(); // e.g. "USDC" + } catch { + return contractId; // not all SEP-41 tokens implement name() + } +} +``` + +Cache these. One RPC round trip per token per sweep is wasteful, and the token list per account is stable. + +### Expected Output Shape + +``` +SweepExecutedMulti { + destination: GBEST...RECIPIENT, + payments: [ + { asset: CA3D5...XLM, amount: 2500000000, timestamp: 1789... }, // 250 XLM + { asset: CCUSDC..., amount: 100000000, timestamp: 1789... }, // 100 USDC (7 dp) + ] +} +``` + +Order follows the contract's internal payment map iteration, not the order you called `record_payment()`. **Do not assert on ordering.** Match on `asset` instead. + +--- + +## Step 5: Verify Balances + +```bash +# Status is Swept +stellar contract invoke --id "$EPHEMERAL_ID" --network testnet \ + --source testnet-investigator -- get_status +# → 2 (Swept) + +# Confirm each token actually arrived at the destination +``` + +Reading the `ephemeral_id` contract's `get_info()` after the sweep still shows the recorded payments — recording is a permanent ledger entry, and the sweep does not clear it. Sweep state is `status` plus `swept_to`; the payment list is history, not a live balance. + +--- + +## Edge Cases + +### Exceeding the 10-Asset Cap + +```bash +# 11th distinct asset +stellar contract invoke --id "$EPHEMERAL_ID" --network testnet --source testnet-payer \ + -- record_payment --amount 1000 --asset "$ELEVENTH_ASSET" +# → Error::TooManyPayments (14) +``` + +Every sweep now fails until the account is retired. There is no way to remove a recorded payment. + +### One Asset With Insufficient Balance + +The sweep reverts in full with `TransferFailed` (controller, code 2) — the mapped error for *any* transfer failure. The contract's `InsufficientBalance` variant is declared but not what surfaces here; `execute_transfers()` maps every token error to `TransferFailed`. Diagnose by checking each asset's actual balance on the ephemeral account, not by expecting a balance-specific error code. + +### Mixed Decimal Precision + +Amounts are raw base units, and precision varies per token: 7 decimals for XLM, 6 for USDC, and whatever a given token declares. Two payments summed by `SweepCompleted.amount` combine incommensurable units. Format per asset, never in aggregate. + +### `SweepExecutedMulti` Fires Even on the Direct Path + +Both `sweep()` and `sweep_claim()` emit `swept_mul`, so you cannot distinguish a controller-driven sweep from a direct `claim()` by that event alone. Correlate with `SweepCompleted` on the controller to tell them apart. + +--- + +## Related Documentation + +- `testnet/config/multi-asset-testing.md` — test tokens and amounts +- `testnet/runbooks/multi-payment-edge-cases.md` — duplicate-asset behaviour in depth +- `testnet/registry/event-topics.md` — `payment` vs `multi_pay` vs `swept_mul` topics +- `testnet/examples/signed-sweep-walkthrough.md` — signing mechanics +- `testnet/examples/gas-free-claim-walkthrough.md` — the alternative sweep path +- `docs/api-reference.md` — `record_payment`, `get_info_paginated`, `simulate_sweep` + +--- + +## Version History + +| Version | Date | Changes | +|---|---|---| +| 1.0 | 2026-09-26 | Initial multi-asset sweep walkthrough | diff --git a/testnet/examples/signed-sweep-walkthrough.md b/testnet/examples/signed-sweep-walkthrough.md new file mode 100644 index 0000000..bce211b --- /dev/null +++ b/testnet/examples/signed-sweep-walkthrough.md @@ -0,0 +1,336 @@ +# Worked Example: Signed Sweep via `SweepController::execute_sweep()` + +## Overview + +This is the **primary** way to move funds out of an `EphemeralAccount`, and the only path that performs real Ed25519 signature verification. This walkthrough covers the three things integrators usually get wrong: + +1. Constructing the message as `SHA256(destination.to_xdr() || nonce_be_u64 || contract_id.to_xdr())` — the exact order and encoding the contract computes. +2. Obtaining a signature without hand-rolling an XDR encoder. +3. Submitting `execute_sweep()` and recognising success vs. each common failure. + +> **Deploy an `EphemeralAccount` first.** This walkthrough starts from an initialized account that has recorded at least one payment. The create-then-pay flow is covered separately in `testnet/examples/create-and-fund-ephemeral-account.md`; if that document is not in your checkout yet, `testnet/config/expiry-ledger-testing.md` has the exact `deploy` + `initialize` + `record_payment` commands. Contract IDs: `testnet/registry/contract-status.md`. + +> **Non-obvious:** do **not** try to call `EphemeralAccount::sweep()` directly. That function accepts an `auth_signature` argument and then ignores it — it only checks that the caller is the `authorized_controller`. Calling it directly gives you a *false* sense that signature checking works. See `testnet/security/unverified-signature-path-warning.md`. + +--- + +## The Signed Message + +### Exact Format + +The contract builds the digest in `contracts/sweep_controller/src/authorization.rs::construct_sweep_message()`: + +``` +message = SHA256( + destination.to_xdr() // Soroban SDK XDR bytes of the destination Address + || nonce.to_be_bytes() // u64, 8 bytes, big-endian + || contract_id.to_xdr() // Soroban SDK XDR bytes of the *SweepController* address +) + +signature = Ed25519_sign(message, authorized_signer_private_key) +``` + +There are exactly three components. **There is no timestamp, no expiry, and no account address in the signed payload.** Older revisions of `docs/SIGNATURE_FORMAT.md` included a timestamp; that was wrong and has been corrected. + +### Component Gotchas + +| Component | Requirement | Common mistake | +|---|---|---| +| `destination.to_xdr()` | Soroban SDK's own serialization | Hand-rolling XDR, or hashing the `G...` strkey string | +| `nonce` | Read from the **live** contract via `get_nonce()` | Using a locally tracked counter | +| `contract_id` | The `SweepController` address, **not** the `EphemeralAccount` | Using the ephemeral account ID, or a mainnet ID | + +The `contract_id` component is what stops a signature for one deployment from being replayed against another. If you deploy a new `SweepController` and reuse the same signer key, **all previously issued signatures are dead** because they were bound to the old contract address. + +### Why You Should Not Hand-Roll the XDR + +`Address::to_xdr()` produces `ScVal::Address` XDR — a discriminant byte, a 4-byte length, then the payload. The discriminant differs between account addresses (`G...`) and contract addresses (`C...`). Getting it subtly wrong produces a signature that fails verification with **no useful error message** (see [Failure Modes](#failure-modes)). + +Use `tools/sweep-signer`, which uses `soroban-sdk` itself to serialize addresses, so the bytes are guaranteed to match what the contract computes on-chain. + +--- + +## Step 1: Read the Current Nonce + +The contract always verifies against its own current on-chain nonce. Read it immediately before signing. + +```bash +SWEEP_CONTROLLER_ID= + +NONCE=$(stellar contract invoke \ + --id "$SWEEP_CONTROLLER_ID" \ + --network testnet \ + --source testnet-investigator \ + -- \ + get_nonce) + +echo "Current nonce: $NONCE" +# → Current nonce: 7 +``` + +A read-only invoke needs no funds beyond the identity's own balance, so a separate `testnet-investigator` identity is enough — the sweep's fee payer can be a different account entirely. + +**Do not cache this value.** The gap between reading the nonce and submitting the sweep is the entire race window. See [Race Conditions](#race-conditions). + +--- + +## Step 2: Sign the Message + +### One-Time: Derive the Public Key + +If you have not yet registered a signer, derive the public key **before** deploying, and pass it to `SweepController::initialize()` as `authorized_signer`: + +```bash +# Generate a signing-only seed (this key never needs to be a funded Stellar account) +SEED=$(node -e "console.log(require('crypto').randomBytes(32).toString('hex'))") +echo "$SEED" # store securely; this is the key that can authorize sweeps + +# Derive the public key to register +cargo run --quiet --manifest-path tools/sweep-signer/Cargo.toml -- \ + pubkey --signer-seed-hex "$SEED" +# → Public key (hex) - put this in AUTHORIZED_SIGNER_PUBLIC_KEY: +# → 16ac79d642e33ac696e822cc7175ad7ddd5587a9dd8b6942640cbb832a1ab4bf +``` + +This same command is what the repo's `.env.example` documents as `AUTHORIZED_SIGNER_PUBLIC_KEY`. + +### Per Sweep: Produce the Signature + +```bash +DESTINATION= + +SIG=$(cargo run --quiet --manifest-path tools/sweep-signer/Cargo.toml -- \ + sign \ + --contract-id "$SWEEP_CONTROLLER_ID" \ + --destination "$DESTINATION" \ + --nonce "$NONCE" \ + --signer-seed-hex "$SEED" \ + | grep '^auth_signature' \ + | awk '{print $NF}') + +echo "Signature: $SIG" +``` + +`sign` also prints the signer's public key and the nonce it used — both are worth reading rather than piping straight through, because they are the two values most likely to be wrong. + +> `tools/sweep-signer` is referenced here but not modified by this document. It accepts the key as either `--signer-seed-hex` (raw 32-byte seed, recommended) or `--signer-secret` (an `S...` strkey). It performs no network I/O. + +**The signing key does not need to be a funded Stellar account.** It is a signing-only Ed25519 key. Only the *relayer* that submits `execute_sweep()` needs XLM. + +--- + +## Step 3: Pre-Flight (Optional but Cheap) + +`EphemeralAccount::simulate_sweep()` reports what a sweep *would* move and whether anything would block it, without changing state: + +```bash +stellar contract invoke \ + --id "$EPHEMERAL_ID" \ + --network testnet \ + --source testnet-investigator \ + -- \ + simulate_sweep \ + --destination "$DESTINATION" +# → (payments, error_code) where error_code == 0 means no blocking error +``` + +`simulate_sweep()` does **not** check the signature, so a passing simulation says nothing about whether your signature is valid. It rules out state problems only. + +There is also a controller-side readiness check: + +```bash +stellar contract invoke \ + --id "$SWEEP_CONTROLLER_ID" \ + --network testnet \ + --source testnet-investigator \ + -- \ + can_sweep \ + --ephemeral_account "$EPHEMERAL_ID" +# → true +``` + +--- + +## Step 4: Submit the Sweep + +The fee payer (`--source`) needs XLM and needs **no** signature-authorizing role — it is just relaying a pre-authorized sweep. + +```bash +stellar contract invoke \ + --id "$SWEEP_CONTROLLER_ID" \ + --network testnet \ + --source testnet-relayer \ + -- \ + execute_sweep \ + --ephemeral_account "$EPHEMERAL_ID" \ + --destination "$DESTINATION" \ + --auth_signature "$SIG" +``` + +### What Happens On-Chain + +1. `validate_destination()` — if the controller was initialized with a locked `authorized_destination`, this must match exactly. +2. `AuthContext::verify()` — reconstructs the digest from the **current on-chain nonce** and calls `ed25519_verify`. A failure here traps. +3. `increment_nonce()` — burns the nonce, invalidating this signature for reuse. +4. `authorize_as_current_contract()` — authorizes the `SweepController` as the invoker of the ephemeral account's `sweep`. +5. `EphemeralAccount::sweep()` — checks state, flips status to `Swept`, emits `SweepExecutedMulti`, reclaims reserve. +6. `transfers::execute_transfers()` — calls SEP-41 `TokenClient::transfer()` once per recorded payment. +7. `SweepCompleted` is emitted on the controller. + +Note the ordering: **the nonce is incremented before the transfer**. If a transfer later fails, the whole transaction reverts atomically and the nonce is *not* burned — so a failed sweep does not consume a nonce. + +--- + +## Step 5: Verify Success + +```bash +# Account status is now Swept +stellar contract invoke \ + --id "$EPHEMERAL_ID" \ + --network testnet \ + --source testnet-investigator \ + -- \ + get_status +# → 2 (Swept) + +# Nonce advanced +stellar contract invoke \ + --id "$SWEEP_CONTROLLER_ID" \ + --network testnet \ + --source testnet-investigator \ + -- \ + get_nonce +# → 8 + +# The controller emitted SweepCompleted +stellar contract events \ + --contract-id "$SWEEP_CONTROLLER_ID" \ + --start-ledger "$START_LEDGER" \ + --filter '{"topics": [["sweep"]]}' \ + --network testnet +``` + +`SweepCompleted.amount` is the **sum of all recorded payment amounts across every asset**. With one USDC payment and one XLM payment it is the arithmetic sum of both, which is not a meaningful monetary figure — do not present it to users as "the amount swept." The per-asset breakdown is in the `SweepExecutedMulti.payments` vector on the `EphemeralAccount` contract. See `testnet/examples/multi-asset-sweep.md`. + +--- + +## Expected Outputs + +| Situation | Result | +|---|---| +| Happy path | `SweepCompleted` + `SweepExecutedMulti` events; status `Swept`; nonce +1 | +| Destination mismatch vs. locked mode | `UnauthorizedDestination` (code 13) | +| Bad/stale signature | Transaction traps in `ed25519_verify`; no error code returned | +| Account already swept | `AlreadySwept` (ephemeral, code 7) | +| Past `expiry_ledger` | `AccountExpired` (ephemeral, code 11) | +| No payment recorded | `NoPaymentReceived` (ephemeral, code 10) | +| Token transfer rejected | `TransferFailed` (controller, code 2) | +| Controller not initialized | `AuthorizedSignerNotSet` (code 10) | + +--- + +## Failure Modes + +### Stale Nonce + +The most common failure by a wide margin. You read nonce 7, someone else's sweep landed, the contract is now at 8, and your signature was built over 7. + +The symptom is a **trap with no error code** — the host `ed25519_verify` function fails and aborts the transaction, so there is no `Error` variant to match on. Re-read `get_nonce()` and re-sign. + +Full diagnosis: `testnet/runbooks/failed-sweep-signature.md`. + +### Wrong Contract ID in the Digest + +Signing against a locally-run controller, a different testnet deployment, or mainnet. The digest binds to `contract_id`, so verification fails exactly as with a bad signature. Check that `--contract-id` matches the ID you pass to `stellar contract invoke --id`. + +### Wrong Signing Key + +`authorized_signer` is baked in at `initialize()` and has no getter and no rotation function. If the stored key differs from the one you are signing with, every signature fails. `sweep-signer sign` prints the public key it used — compare it against the value in `deployments/testnet.json` under `config.authorizedSigner`. + +### Locked-Mode Destination + +If the controller was initialized with an `authorized_destination`, only that address is accepted. This surfaces as `UnauthorizedDestination`, checked *before* signature verification, so it is a clean error rather than a trap. + +While the nonce is still 0 the creator can correct this with `update_authorized_destination()`. Once any sweep has succeeded the nonce is > 0 and that call fails with `AccountAlreadySwept` — the lock is permanent. + +--- + +## Race Conditions + +`SweepController` has a single global nonce shared by **all** ephemeral accounts it controls. Any concurrent sweep — yours, another integrator's, a load test — consumes it. + +``` +Signer A reads nonce 7 ──┐ +Signer B reads nonce 7 ──┼──► Both build a valid-looking signature +Signer A submits ────────┤ A succeeds, nonce → 8 +Signer B submits ────────┘ B traps in ed25519_verify +``` + +**The testnet controller is shared**, so this is not hypothetical — see `testnet/security/known-testnet-abuse-patterns.md`. If you run parallel sweeps: + +- Serialize signing per controller (a mutex or a single-flight queue), and +- Treat an `ed25519_verify` trap as a signal to re-read the nonce and retry, not as a hard failure. + +Do not build a retry loop that hammers a shared controller; back off and cap attempts. + +--- + +## Full Script + +```bash +#!/bin/bash +# signed-sweep.sh - one signed sweep against the shared testnet controller +set -euo pipefail + +SWEEP_CONTROLLER_ID="${SWEEP_CONTROLLER_ID:?set to the testnet SweepController ID}" +EPHEMERAL_ID="${EPHEMERAL_ID:?set to the initialized EphemeralAccount ID}" +DESTINATION="${DESTINATION:?set to the recipient G address}" +SEED="${SEED:?set to the authorized signer seed (64 hex chars)}" + +# 1. Read the live nonce immediately before signing +NONCE=$(stellar contract invoke \ + --id "$SWEEP_CONTROLLER_ID" --network testnet --source testnet-investigator \ + -- get_nonce) +echo ">> nonce: $NONCE" + +# 2. Sign +SIG=$(cargo run --quiet --manifest-path tools/sweep-signer/Cargo.toml -- sign \ + --contract-id "$SWEEP_CONTROLLER_ID" \ + --destination "$DESTINATION" \ + --nonce "$NONCE" \ + --signer-seed-hex "$SEED" \ + | grep '^auth_signature' | awk '{print $NF}') + +# 3. Submit (relayer pays fees; needs XLM but no special role) +stellar contract invoke \ + --id "$SWEEP_CONTROLLER_ID" --network testnet --source testnet-relayer \ + -- execute_sweep \ + --ephemeral_account "$EPHEMERAL_ID" \ + --destination "$DESTINATION" \ + --auth_signature "$SIG" + +# 4. Verify +stellar contract invoke \ + --id "$EPHEMERAL_ID" --network testnet --source testnet-investigator \ + -- get_status # expect 2 (Swept) +``` + +--- + +## Related Documentation + +- `docs/SIGNATURE_FORMAT.md` — message format spec, with per-language examples +- `testnet/config/nonce-tracking.md` — nonce behaviour and pitfalls in depth +- `testnet/runbooks/failed-sweep-signature.md` — diagnosing a rejected signature +- `testnet/examples/gas-free-claim-walkthrough.md` — the recipient-signs/relayer-submits alternative +- `testnet/examples/multi-asset-sweep.md` — sweeping more than one asset +- `testnet/security/unverified-signature-path-warning.md` — why not to call `EphemeralAccount::sweep()` directly +- `tools/sweep-signer/` — the signing tool (referenced, not modified here) + +--- + +## Version History + +| Version | Date | Changes | +|---|---|---| +| 1.0 | 2026-09-26 | Initial signed sweep walkthrough |