From d5335f91e0622c948f4099f72a98d08e9aa38fd9 Mon Sep 17 00:00:00 2001 From: Hassan Oladipupo <109126045+Hassan-oladipupo@users.noreply.github.com> Date: Sat, 26 Sep 2026 14:21:14 +0100 Subject: [PATCH] docs(testnet): integration-harness guidance and tooling specs with a decision log Adds the testnet-facing tooling specifications batch, plus guidance for consuming projects running their own integration suite against the live deployment. - examples/integration-test-harness-notes.md: latency budgets, test account funding/cleanup strategy, and how to keep a suite from interfering with other integrators on the shared deployment. Records that expiry tests are inherently ledger-bound because the repo has no local sandbox. - tooling/nonce-inspector-spec.md: read-only helper printing the current on-chain nonce, with multi-sample stability detection so nonce churn is visible before submitting. - tooling/faucet-helper-spec.md: one-step create/fund/deploy/initialise, defaulting expiry to a human duration and echoing the immutable recovery_address so it cannot be set wrong silently. - tooling/decision-log.md: records that this batch was scoped away from scripts/, that specs-not-tools here is deliberate for now, and that where runnable tools should live remains undecided. Also flags that a future local sandbox would obsolete most of these specs. Specs are deliberately not implemented; each opens with an explicit status line. No contract, script, or tool changes. tools/sweep-signer is referenced contextually and not modified. Closes #642 Closes #647 Closes #648 Closes #649 --- .../integration-test-harness-notes.md | 305 ++++++++++++++++++ testnet/tooling/decision-log.md | 104 ++++++ testnet/tooling/faucet-helper-spec.md | 245 ++++++++++++++ testnet/tooling/nonce-inspector-spec.md | 218 +++++++++++++ 4 files changed, 872 insertions(+) create mode 100644 testnet/examples/integration-test-harness-notes.md create mode 100644 testnet/tooling/decision-log.md create mode 100644 testnet/tooling/faucet-helper-spec.md create mode 100644 testnet/tooling/nonce-inspector-spec.md diff --git a/testnet/examples/integration-test-harness-notes.md b/testnet/examples/integration-test-harness-notes.md new file mode 100644 index 0000000..8347c3c --- /dev/null +++ b/testnet/examples/integration-test-harness-notes.md @@ -0,0 +1,305 @@ +# Integration Test Harness Notes: Testing Against Live Testnet + +## Overview + +`scripts/test.sh` runs `cargo test` for all four contracts against an **in-process Soroban test environment** — no network, no deployed contracts, no fees. That is the right tool for contract logic and the wrong tool for anything that depends on a real deployment. + +This document is for teams writing **their own** integration suite against the live testnet deployment: what latency to budget, how to manage test accounts, and — the hard part — how to stop your tests from interfering with each other or with other integrators on a shared deployment. + +> **Scope.** This is guidance for a *consuming* project's test suite. Nothing here changes `scripts/test.sh`, and nothing here adds a sandbox entrypoint; the repo has no local Soroban sandbox, which is itself a documented gap (see [Why Not a Sandbox](#why-not-a-sandbox)). + +--- + +## What Integration Tests Actually Catch + +Worth being explicit about the split, because it determines what belongs in which suite. + +| Concern | `cargo test` (in-process) | Live-testnet integration | +|---|---|---| +| State machine and error codes | ✅ | redundant | +| Ed25519 verification logic | ✅ | redundant | +| Reentrancy ordering | ✅ | redundant | +| Nonce desync from concurrent submitters | ❌ | ✅ **only here** | +| Deploy script / WASM hash correctness | ❌ | ✅ | +| Contract actually exists at the expected ID | ❌ | ✅ | +| Wasm-vs-spec interface drift | ❌ | ✅ | +| RPC availability, latency, rate limits | ❌ | ✅ | +| SEP-41 token behaviour against a real token | partially | ✅ | +| Balance conservation with real transfers | ❌ | ✅ | + +If your test asserts an error code, it belongs in `cargo test`. If it asserts that the shared testnet deployment is in a state you can use, it belongs here. + +--- + +## Latency: Budget It Properly + +Testnet is not a local sandbox. Rough expectations against `soroban-testnet.stellar.org`: + +| Operation | Typical wall time | +|---|---| +| Ledger close | ~5 s | +| Single contract invoke (submitted → confirmed) | 5–15 s, occasionally 30 s+ | +| `simulateTransaction` RPC round trip | 200 ms – 2 s | +| `getEvents` over a wide ledger range | 1 – 10 s (scales with range) | +| Friendbot funding | 1 – 5 s, rate-limited (see below) | + +### Consequences for suite design + +1. **Budget 15 s per state-changing step, not 2 s.** A suite that assumes fast testnet will flake on a bad day and train you to ignore flakes. +2. **Sequence the suite, do not parallelise state transitions.** A test that creates an account and the next test that funds it cannot run concurrently. Serial execution is a feature here. +3. **Read-only calls may be batched or parallelised.** `get_info`, `is_expired`, `get_nonce` are safe to fan out. +4. **Prefer `simulateTransaction` over a submitted transaction for pre-flight checks.** `simulate_sweep()` exists precisely for this — it reports what a sweep would move and any blocking error without changing state, at a fraction of the latency. +5. **Never use a fixed sleep as a synchronisation primitive.** Poll for the condition with a timeout. +6. **Expect testnet to be slow at ledger boundaries.** Ingestion pauses briefly around close; a submission that lands then may take a full extra cycle. + +```typescript +// Budget the wait on the condition, not on a guess. +async function waitFor( + predicate: () => Promise, + { timeoutMs = 30_000, intervalMs = 2_000, label = 'condition' } = {}, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await predicate()) return; + await sleep(intervalMs); + } + throw new Error(`timed out after ${timeoutMs}ms waiting for ${label}`); +} + +// Never: await sleep(5000) +// Always: await waitFor(() => isExpired(account), { label: 'expiry' }); +``` + +--- + +## Test Account Funding and Cleanup Strategy + +### Funding is the bottleneck, not contract calls + +Friendbot is rate-limited per address. A suite that creates a fresh Stellar account per test will spend most of its wall time being throttled, not testing. The practical guidance in `testnet/config/friendbot.md` (funding patterns, and the limits that force them) is the reference here. + +Three strategies, in increasing order of preference: + +#### 1. Reuse a small pool of funded accounts (recommended) + +Create **N** accounts once per suite run — say 3–5 — and reuse them across tests. Each holds enough XLM to act as a fee payer and hold token balances. + +``` ++ setup: create 4 funded accounts (~4 Friendbot calls, one time) ++ per test: reuse from the pool (0 Friendbot calls) +``` + +#### 2. Reuse across runs by persisting keys + +Write pool accounts to a gitignored file (or CI secret store) and reuse them on subsequent runs. Eliminates Friendbot from the hot path entirely. + +**Never commit testnet secret keys.** Testnet XLM is worthless, but a committed key trains the wrong habit, gets picked up by secret scanners, and — once a key *is* reused on mainnet — becomes a real incident. + +#### 3. Fresh account per test (only for isolation-critical tests) + +Accept the latency. Restrict this to tests that genuinely need a virgin account (expiry, one-shot recovery), and cap the count. + +### `EphemeralAccount` instances are per-test regardless + +The funded *Stellar* account is the reusable resource. The `EphemeralAccount` **contract instance** is not — it is single-use by design: + +- One payment per asset; a second `record_payment` for the same asset returns `DuplicateAsset` (13). +- One sweep, ever: after `Swept`, further sweeps fail with `AlreadySwept` (7). +- `expiry_ledger` and `recovery_address` are fixed at `initialize()` and cannot be changed. +- `record_payment()` has no status or expiry guard, so you *can* pollute an expired account — but then it can never be swept, so do not. + +**Every test that needs a sweepable account needs a freshly deployed instance.** This is the main cost driver in a live-testnet suite. If you deploy many, `AccountFactory::batch_initialize` is the intended path — with the caveat in [Caveats](#caveats-worth-knowing) below. + +### Cleanup + +There is no close, no revoke, no "delete". Options: + +| Approach | When | +|---|---| +| Let accounts expire | Accounts created with a short `expiry_ledger` self-retire the state machine. They still hold balances and still cost reserve. | +| `expire()` at teardown | Flips status to `Expired`, one-shot. Bookkeeping only — **does not return tokens** (see below). | +| Leave them | Acceptable on testnet. Set a far-future expiry so nothing expires mid-debug. | +| Sweep to a burn destination | Only meaningful for accounts that are actually sweepable. | + +**Cleanup does not reclaim anything.** `expire()` and `recover()` set state and emit `AccountExpired`; `ephemeral_account` contains no `TokenClient::transfer()` call, so no tokens move to the `recovery_address`. Reserve reclaim is a counter, not a transfer. "Cleaning up" is tidying your own test state, not returning funds. Plan for that — if your assertions depend on a swept-away balance, that will not happen on the expiry path. + +--- + +## Not Interfering With Shared Testnet State + +This is the part that makes live-testnet integration testing a community problem rather than a purely local one. **The testnet deployment is shared.** Other integrators, this repo's own documentation examples, and other people's CI are all hitting the same contracts at the same time. + +### The single global nonce is the main hazard + +`SweepController` keeps **one** nonce for **all** accounts it controls. Every successful `execute_sweep()` or `claim()` increments it. So: + +``` +Your test reads nonce = 12 +Someone else's sweep lands +You sign over nonce 12 +You submit → traps in ed25519_verify +``` + +This is not a rare race — on a busy shared controller it is routine. See `testnet/config/nonce-tracking.md` for the mechanics and `testnet/security/known-testnet-abuse-patterns.md` for the abuse framing. + +**Rules that follow from it:** + +1. **Read `get_nonce()` immediately before signing**, never cache it across a test step. +2. **Serialise signing per controller** in your suite. One in-flight sweep at a time. +3. **Treat an `ed25519_verify` trap as retryable**, not as a hard failure. Re-read the nonce and retry. +4. **Bound the retries.** A tight retry loop against a shared controller is itself the abuse pattern. Cap at 2–3 attempts with backoff, then fail with a message pointing at nonce contention rather than looping. +5. **Log the nonce you used** on every sweep, so a failure is diagnosable afterwards. + +### Who you must not break + +| Shared resource | How to avoid breaking it | +|---|---| +| `SweepController` nonce | Serialise; re-read before each signature | +| Locked `authorized_destination` | If the shared controller is in locked mode, sweep **only** to that address. Check before assuming flexible mode. | +| The `authorized_signer` key | You do not have it. If sweeps fail with a trap and your digest is right, the signer key may have rotated — not your bug. | +| `EphemeralAccount::upgrade()` admin | Never call `upgrade()` against the shared deployment. Not "carefully", not "just once". | +| `SweepController::update_authorized_destination()` | Creator-gated and changes routing for **every** account on that controller. Treat as forbidden. | +| Long-lived demo accounts | `testnet/registry/known-test-accounts.md` accounts are never to be swept or expired. | +| Test tokens' balances | Other integrators use the same SEP-41 tokens. Do not assume a token's supply or any account's balance. | + +The broad version: **your test suite may create its own accounts, but it may not change shared configuration.** Everything that mutates controller-level or contract-level config is off-limits against the shared deployment. + +### Naming everything + +Because you cannot see other integrators' transactions in a useful way, make yours identifiable: + +```typescript +const SUITE = 'myorg-sdk-it'; // your team +const RUN = process.env.CI_RUN_ID ?? String(Date.now()); +const label = (what: string) => `${SUITE}/${RUN}/${what}`; // → recovery_address +``` + +Recovery addresses and `authorized_controller` values are not settable after `initialize()`, so the label has to be correct on the first try. Put the run id in the recovery address, and you can identify your accounts later from on-chain state alone. + +### Detect cross-contamination + +```typescript +// If the controller is in locked mode, every sweep must target one address. +// Assert against the address you expect rather than assuming flexibility. +const destination = await resolveAuthorizedDestination(controllerId); +``` + +There is **no getter** for `authorized_destination` or `authorized_signer` on `SweepController` — you infer them from `execute_sweep` behaviour (`UnauthorizedDestination`) and from `deployments/testnet.json`. If your suite assumes flexible mode and it is actually locked, every test fails with `UnauthorizedDestination` and it will look like a bug in your code. Check this first when a whole suite fails identically. + +### Suite-level etiquette + +- Prefer a **dedicated** `EphemeralAccount` per test; never share a sweepable account across tests. +- Read-only tests can run anywhere, any time. +- Avoid submitting sweeps in a tight loop. A handful per run is polite; hundreds is a nuisance and can starve other integrators. +- Prefer a locally-run `cargo test` for anything you *can* test there. Reserve live testnet for what genuinely needs a real deployment — it is a shared, rate-limited, occasionally unreliable resource. + +--- + +## Assertions Worth Making + +A live-testnet suite earns its keep on assertions an in-process test cannot make: + +```typescript +// 1. The deployment exists and responds +await expect(contract.get_status()).resolves.toBeDefined(); + +// 2. The WASM you expect is what's deployed +// (guards against a silent upgrade — see testnet/registry/upgrade-history.md) +expect(await liveWasmHash(controllerId)).toBe(EXPECTED_WASM_HASH); + +// 3. State machine holds on a real deployment +const acct = await freshEphemeralAccount({ expiryLedgers: 60 }); +await expect(acct.sweep(...)).rejects.toThrow(/NoPaymentReceived/); + +// 4. One payment per asset, on-chain +await acct.record_payment(100n, USDC); +await expect(acct.record_payment(50n, USDC)).rejects.toThrow(/DuplicateAsset/); + +// 5. Expiry is ledger-based and inclusive +expect(await acct.is_expired()).toBe(false); +await waitFor(() => acct.is_expired(), { label: 'expiry' }); +await expect(acct.sweep(...)).rejects.toThrow(/AccountExpired/); + +// 6. Funds actually moved (the assertion in-process tests CANNOT make) +const before = await token.balanceOf(destination); +await sweep(...); +expect(await token.balanceOf(destination)).toBe(before + EXPECTED); +``` + +Assertion 6 is the highest-value one in the list. `SweepController` is the only path that calls SEP-41 `transfer()`; if balances do not move, nothing moved. + +--- + +## Sizing the Suite + +A pragmatic live-testnet suite is **small and targeted** — everything that cannot be proven in-process, and nothing else. + +| Suite layer | Tests | Runtime target | +|---|---|---| +| Contract logic (in-process, `cargo test`) | everything else | seconds | +| Live smoke: deployment reachable, WASM hash matches | 1 test | ~15 s | +| Happy-path sweep, real transfers | 1–2 tests | ~60 s | +| Expiry path | 1 test | ~5 min (ledger-bound) | +| Concurrency / nonce contention | 1 test | ~30 s | + +If your live suite is taking more than a few minutes, most of what it is doing belongs in `cargo test`. Exception: expiry tests are inherently ledger-bound — a ~5-minute expiry is about 60 ledgers, and there is no way to skip real ledgers against a live network. + +--- + +## Handling Flakiness + +| Symptom | Likely cause | Response | +|---|---|---| +| `ed25519_verify` trap, correct digest | Nonce contention on the shared controller | Re-read nonce, retry (bounded) | +| Whole suite fails with `UnauthorizedDestination` | Controller is in locked mode | Assert against the locked address | +| `AccountExpired` before you expected | Ledger arithmetic; `is_expired()` is `>=` | Widen the margin; recompute from a fresh ledger read | +| Timeout waiting for confirmation | Testnet lag or ingestion pause | Raise the timeout; do not shrink it | +| Friendbot throttled | Too many fresh accounts per run | Switch to a reused account pool | +| `TransferFailed` | One asset lacks balance; the whole sweep reverts | Check every recorded asset's balance | +| Works locally, fails on testnet | Missing `require_auth` signer | Confirm which identity actually signed | + +Distinguish *flaky* from *wrong* before adding a retry. A retry that makes a genuinely broken test pass is worse than a failure. + +--- + +## Caveats Worth Knowing + +- **`AccountFactory` swallows per-account error detail.** `batch_initialize` returns `success: false, error: None`, so a partial failure tells you *which* address failed but not why. Diagnose each failed address by hand — `testnet/runbooks/account-factory-batch-failure.md`. +- **`AccountFactory` is not built by `scripts/build.sh`**, is not in CI, and is not deployed by `scripts/deploy-testnet.sh`. Confirm it is actually live before building a suite around it — `testnet/registry/account-factory-status.md`. +- **`scripts/deploy-testnet.sh` is currently broken.** It references an unassigned `RESERVE_CONTRACT_ID` under `set -euo pipefail` and aborts. If your suite expects a fresh testnet deployment to exist, it does not. +- **No local sandbox exists.** `soroban sandbox` / `stellar sandbox` is referenced in `testnet/config/expiry-ledger-testing.md` as an option, but the repo provides no entrypoint for it. Until one lands, ledger-bound tests genuinely take real time. +- **`SweepController` has no getters** for `creator`, `authorized_signer`, or `authorized_destination`. Infer them, or read `deployments/testnet.json` — and treat that file as a record, not a guarantee. Verify on-chain where you can. +- **Contract IDs change on redeploy.** Pin to what `deployments/testnet.json` says today and re-verify; a redeploy under new IDs will silently break a hardcoded suite. + +--- + +## Why Not a Sandbox + +The obvious objection to all of this latency is: why not run a local Soroban sandbox with instant ledgers? + +Because there isn't one. The repo has no sandbox entrypoint, no local deployment script, and CI that is entirely commented out (`.github/workflows/test.yml` is inert; `deploy-testnet.yml` validates the build but has its actual deploy steps commented out). So live testnet is currently the only environment that reflects a real deployment. + +That is a real gap, and closing it would remove most of what is painful in this document. Until then, the mitigation is the one above: keep the live suite small, keep the rest in `cargo test`, and be a good citizen of a shared deployment. + +--- + +## Related Documentation + +- `testnet/config/nonce-tracking.md` — the shared nonce, and why it is the main contention point +- `testnet/config/expiry-ledger-testing.md` — computing short expiries for ledger-bound tests +- `testnet/config/soroban-cli-identity-setup.md` — the testnet identities referenced here +- `testnet/config/friendbot.md` — funding patterns and rate limits +- `testnet/security/known-testnet-abuse-patterns.md` — the same hazards, from the griefing angle +- `testnet/registry/known-test-accounts.md` — long-lived accounts your suite must not touch +- `testnet/registry/upgrade-history.md` — correlating behaviour changes to upgrades +- `testnet/registry/contract-status.md` — deployment status and contract IDs +- `testnet/examples/` — worked examples of the flows under test +- `docs/testing.md` — the in-process test suite these notes complement + +--- + +## Version History + +| Version | Date | Changes | +|---|---|---| +| 1.0 | 2026-09-26 | Initial integration test harness guidance for live testnet | diff --git a/testnet/tooling/decision-log.md b/testnet/tooling/decision-log.md new file mode 100644 index 0000000..bba9be4 --- /dev/null +++ b/testnet/tooling/decision-log.md @@ -0,0 +1,104 @@ +# Decision Log: Why Tooling Specs Live in `testnet/tooling/` + +## Purpose of this file + +This is a record of a decision, so that a future contributor does not "fix" the current state of `testnet/tooling/` by moving runnable tools into it — or by concluding the directory is broken because it contains only Markdown. + +The short version: + +> **`testnet/tooling/` currently holds specifications, not runnable scripts. That is deliberate for this batch of work, and it is not a claim that this directory is the right long-term home for runnable tools. That question has not been decided.** + +Related: `testnet/tooling/README.md` is the index for this directory. This file is the reasoning behind it. + +--- + +## The Decision + +The documentation batch that produced this directory was **explicitly scoped to avoid `scripts/`**. Contributors were asked to specify testnet-facing tooling, not to build it, and not to add runnable tooling to the repository's script directory. + +The natural place for a spec is next to the index that points at it, so specs landed in `testnet/tooling/`. + +## Why This Needs Writing Down + +The resulting state — a directory called `tooling/` that contains no tools — looks like an error. There are at least three reasonable-but-wrong reactions: + +| Reaction | Why it is wrong | +|---|---| +| "There's no code here, so the tooling was never written — let me add the scripts." | The batch was scoped to specs. Writing the tools is a separate, larger piece of work that has not been scoped or agreed. | +| "The tools clearly belong here, so let me move the existing scripts into `testnet/tooling/`." | Nothing has established this as the right home. `scripts/` has a real existing purpose (see below). Moving files would be a silent architectural decision. | +| "Specs are the deliverable, so this directory is complete." | The specs describe tools that do not exist. Anyone expecting to run them will be misled unless they read the status line at the top of each spec. | + +Every spec in this directory therefore opens with an explicit **Status: spec only, nothing implemented** line. That is the mitigation for the current state, and this file is the durable explanation of why it is the way it is. + +--- + +## What `scripts/` Is For + +`scripts/` is not empty or unused. It holds the repository's own build and CI entrypoints: + +| Script | Purpose | +|---|---| +| `scripts/build.sh` | Builds the contract WASMs | +| `scripts/deploy-testnet.sh` | Deploys contracts to testnet | +| `scripts/test.sh` | Runs `cargo test` across the workspace | + +These are **project-maintenance scripts**: they run in the repo, are invoked by developers and CI, and operate on the repository's own build output. They are versioned with the code they build. + +`testnet/tooling/` specs describe something categorically different: **helper tools for people and CI suites consuming the already-deployed contracts.** A nonce inspector or a faucet helper is used by integrators against a live deployment. It has no build step, no place in this repo's CI, and no reason to be versioned in lockstep with the contract source. + +That distinction — *scripts that build this repo* vs. *tools that consume this repo's deployment* — is why the two are not the same thing, and why moving files between them without deciding would be wrong. + +## What Is Still Undecided + +Honest inventory of the open questions. **None of these has been decided.** + +1. **Do the specified tools get built at all?** Nothing commits to implementing them. They are proposals. +2. **If built, where do they live?** Options include `testnet/tooling/`, a separate top-level `tools/` package (alongside the existing `tools/sweep-signer/`), an entirely separate repository, or distribution as SDK subcommands. This has not been chosen. +3. **What language and distribution model?** The existing `tools/sweep-signer/` is Rust. That is a precedent, not a decision. +4. **Who maintains them?** The repo has no `CONTRIBUTING.md`. Maintenance expectations for integrator-facing tools are undefined. +5. **What is the stability promise?** Whether these are experimental conveniences or something a consuming project's CI may depend on has not been discussed. +6. **Would a local sandbox change the calculus?** See below — it may make some of these tools unnecessary. + +Any of these being answered is a prerequisite for moving runnable code into this directory. + +--- + +## What Would Count as "Fixing" This + +Legitimate follow-ups, in rough order of value: + +1. **Answer the questions above and write the answers here.** This is the real fix. A recorded decision is worth more than any amount of code. +2. **Build one tool as a pilot**, in whatever location the decision names, and see whether the location holds up in practice. +3. **Revisit the specs against a real implementation.** Specs written without code tend to miss things; that is expected and worth revisiting rather than treating as a defect. +4. **Do not** silently relocate existing scripts, and do not treat the absence of code here as a bug to be patched by moving things. + +--- + +## The Dependency That Might Obsolete All of This + +Several specs in this directory assume the repo has **no local Soroban sandbox** — so every manual step goes through the live shared testnet, and helpers that smooth that path are worth building. + +That is the current state: there is no sandbox entrypoint, and the CI workflows that would have provided one are largely inert (`.github/workflows/test.yml` is entirely commented out; `deploy-testnet.yml` validates the build but has its actual deploy steps commented out). `testnet/config/expiry-ledger-testing.md` mentions `soroban sandbox` as an option, but the repo provides no way to run it. + +If a local sandbox is ever added, most of these tools get substantially less useful — instant ledgers remove the need for careful expiry arithmetic, and a local deployment removes most of the latency. **Re-evaluate these specs against a sandbox before implementing them.** Building a nonce inspector to smooth over live-testnet races is a workaround for a missing sandbox, not a permanent need. + +This is a real risk to the whole batch, and it is another reason not to treat these documents as settled designs. + +--- + +## Related Documentation + +- `testnet/tooling/README.md` — index of the specs in this directory +- `testnet/tooling/nonce-inspector-spec.md` — spec; supports `testnet/config/nonce-tracking.md` +- `testnet/tooling/faucet-helper-spec.md` — spec; builds on `testnet/config/friendbot.md` +- `tools/sweep-signer/` — the one real signing tool that does exist +- `scripts/` — repo build/CI scripts; a different category of thing +- `testnet/config/README.md` — the same read-only/no-secrets discipline applied to `testnet/config/` + +--- + +## Version History + +| Version | Date | Changes | +|---|---|---| +| 1.0 | 2026-09-26 | Initial decision log for `testnet/tooling/` | diff --git a/testnet/tooling/faucet-helper-spec.md b/testnet/tooling/faucet-helper-spec.md new file mode 100644 index 0000000..e4d2583 --- /dev/null +++ b/testnet/tooling/faucet-helper-spec.md @@ -0,0 +1,245 @@ +# Spec: Faucet Helper — Fund and Initialise a Fresh Test `EphemeralAccount` in One Step + +## Status + +**Spec only. Nothing in this document is implemented.** See `testnet/tooling/decision-log.md` for why this directory holds specifications rather than runnable scripts. + +## Problem + +Every manual testnet experiment starts with the same three-step dance, and every newcomer gets it slightly wrong: + +1. Create a Stellar account and **fund it with Friendbot** (XLM is required before the account exists on-ledger, and before it can pay any fee). +2. `stellar contract deploy` a new `ephemeral_account` WASM instance. +3. `stellar contract invoke ... initialize` with six arguments — two of which (`expiry_ledger` and `recovery_address`) are easy to get wrong and **cannot be corrected afterwards**. + +Then, to do anything useful, a fourth step: fund the new contract with a SEP-41 token, because `record_payment()` only records bookkeeping — if the account has no balance, the eventual sweep fails with `TransferFailed`. + +That is four commands, roughly ten seconds of RPC latency, and two irreversible parameters. A tester running this fifty times is fifty opportunities to type a bad `recovery_address`. + +A single helper that does steps 1–3 — optionally 4 — removes the class of error entirely. + +> Funding behaviour, Friendbot limits, and the workarounds for throttling are documented in `testnet/config/friendbot.md`. That document is the reference; this is a proposal for a tool layered on top of it. + +## Scope + +**In scope:** create → fund → deploy → initialise, with correct-by-construction parameter handling, and print a summary an integrator can copy. + +**Out of scope:** + +- Recording payments. The tester chooses the asset and amount; guessing would be worse than useless. +- Sweeping. Different tool, different risk profile. +- Managing Friendbot rate limits beyond surfacing them clearly. +- Anything that touches contracts not owned by this deployment. + +--- + +## Interface + +### Invocation + +```bash +bridgelet-faucet [options] +``` + +### Options + +| Option | Default | Purpose | +|---|---|---| +| `--expiry ` | `1h` | Expiry, by ledger count or human duration. See below. | +| `--recovery ` | caller's address | Recovery address. **Immutable after init** — confirm before deploying. | +| `--admin ` | caller's address | Upgrade admin. Immutable after init. | +| `--controller ` | shared `SweepController` ID | `authorized_controller`. Must be a real controller. | +| `--creator ` | caller's address | `creator`; also authorises `initialize()`. | +| `--identity ` | caller's active identity | Stellar identity to operate as. | +| `--token :` | none | Optional SEP-41 funding, repeatable. | +| `--wasm ` | built `ephemeral_account.wasm` | WASM to deploy. | +| `--dry-run` | off | Print the full plan and exit. Deploys nothing. | +| `--json` | off | Machine-readable output. | +| `--rpc-url ` | `https://soroban-testnet.stellar.org` | RPC endpoint. | + +### Output + +``` +✔ funded testnet-harness GABC… (10000 XLM) +✔ deployed CDEF… + recovery GABC… (your address) + controller CBEU… (shared SweepController) + admin GABC… + expiry_ledger 17894512 (+720 ≈ 1h from 17893792) +✔ initialized expiry_ledger 17894512 + + ephemeral account CDEF… + recovery address GABC… + controller CBEU… + admin GABC… + expires at ledger 17894512 (~1h) + +Copy this block into your test: + EPHEMERAL_ID=CDEF… + RECOVERY_ADDRESS=GABC… + SWEEP_CONTROLLER_ID=CBEU… +``` + +The copyable block matters. The point is not that the helper runs the commands; it is that it **emits the resulting identifiers in a form that can be pasted into a test without retyping them.** + +JSON: + +```json +{ + "identity": "testnet-harness", + "ephemeral_id": "CDEF...", + "recovery_address": "GABC...", + "authorized_controller": "CBEU...", + "admin": "GABC...", + "expiry_ledger": 17894512, + "funding": [ + { "asset": "CA3D5...", "amount": "2500000000" } + ] +} +``` + +Note `amount` as a **string**. Token amounts are `i128`; JSON numbers lose precision above 2^53. + +--- + +## Behaviour + +### Stage 1 — Fund + +```bash +stellar keys create --global "$IDENTITY" # if it does not exist +stellar keys fund "$IDENTITY" --network testnet # Friendbot +``` + +A Stellar account must exist and be funded on-ledger before it can appear as a contract argument or pay a fee. If the identity already exists and is funded, skip — but **check** rather than assume, because an unfunded account fails later at `initialize()` with a confusing auth error. + +Report Friendbot throttling distinctly from other failures. It means "slow down and retry", not "something is wrong". The limits and the workarounds are in `testnet/config/friendbot.md`. + +### Stage 2 — Deploy + +```bash +stellar contract deploy --wasm "$WASM" --network testnet --source "$IDENTITY" +``` + +If the WASM is missing, say so and print the build command. Do **not** silently build or substitute a different artefact — deploying the wrong WASM produces an account that behaves unexpectedly much later. + +### Stage 3 — Initialise + +Six arguments, all required, all in one call: + +```bash +stellar contract invoke --id "$EPH_ID" --network testnet --source "$IDENTITY" -- initialize \ + --creator "$CREATOR" \ + --expiry_ledger "$EXPIRY" \ + --recovery_address "$RECOVERY" \ + --authorized_controller "$CONTROLLER" \ + --admin "$ADMIN" +``` + +`initialize()` sets every one of these exactly once. A second call returns `AlreadyInitialized` (1) and there is **no setter for any of them**. The two most consequential: + +- **`recovery_address`** — the destination of the whole recovery path. Wrong means funds are unrecoverable by you. +- **`expiry_ledger`** — must be strictly in the future (`InvalidExpiry`, code 5) or the account is dead on arrival; must be far enough out that ledger timing does not surprise you. + +The helper's real value is refusing to let either be wrong silently. If `--recovery` was not passed, default to the caller's own address and **echo it prominently** — a recovery address you did not consciously choose is the kind of thing nobody notices until it matters. + +### `--expiry` parsing + +| Input | Ledgers added | Approximate wall time | +|---|---|---| +| `5m` | 60 | ~5 min | +| `1h` | 720 | ~1 hr | +| `1d` | 17280 | ~24 hr | +| `1w` | 120960 | ~7 days | +| `3600` (raw) | 3600 | ~5 hr | + +Read the live ledger first — `stellar network current-ledger` — and add. Never derive the expiry from a local clock; `expiry_ledger` is a **ledger sequence number**, and `is_expired()` uses `current >= expiry`, inclusive, so the boundary ledger is already expired. The arithmetic and its rationale are in `testnet/config/expiry-ledger-testing.md`. + +### Stage 4 — Optional token funding + +```bash +--token CA3D5…:2500000000 +``` + +`record_payment()` is **bookkeeping only** — it does not move value. Without an actual balance, the sweep later reaches `transfers::execute_transfers()` and reverts with `TransferFailed`, taking the whole multi-asset sweep down with it. Offering `--token` here prevents the most common "it initialised fine but the sweep fails" report. + +Do not call `record_payment()` automatically. The tester picks the asset and amount. + +### `--dry-run` + +Print every command, every resolved address, and the computed `expiry_ledger`, then exit without touching the network. This is how you check a `--recovery` value before committing to it. + +--- + +## Failure Handling + +| Stage | Failure | Handling | +|---|---|---| +| Fund | Friendbot throttled | Report as throttle with a retry hint; do not treat as fatal | +| Fund | Account exists, unfunded | Re-fund, continue | +| Deploy | WASM not found | Stop; print the build command | +| Deploy | Insufficient XLM | Report balance and required amount | +| Init | `InvalidExpiry` (5) | Computed expiry ≤ current ledger; recompute and retry once | +| Init | `AlreadyInitialized` (1) | Should be impossible on a fresh deploy; report loudly, as it means a hash/deploy collision | +| Init | auth failure | Caller did not authorise. The identity must be the `creator` | +| Init | `NotInitialized` (2) | Deployed but not initialised — check the WASM is really `ephemeral_account` | +| Token fund | Insufficient balance | Name the asset; do not retry blindly | +| Any | RPC unreachable | Distinct exit code from contract errors | + +### Exit codes + +`0` success · `1` contract returned an error · `2` usage error · `3` RPC unreachable · `4` Friendbot throttled · `5` prerequisite missing (WASM, identity) + +--- + +## Ordering and Idempotency + +The order is not arbitrary: fund → deploy → initialise → token-fund. Each stage depends on the previous one having landed on-chain, so the helper must **wait for confirmation between stages** rather than firing them optimistically. + +A helper that does not wait produces accounts that initialise against an unconfirmed deploy, which fails in a way that looks like a contract bug. + +**Not idempotent.** Every run creates a new Stellar account and a new contract instance. There is no "reuse" mode, deliberately: `EphemeralAccount` is single-use by design (one payment per asset, one sweep ever, immutable recovery address), so reusing one instance across experiments is a reliable source of confusing failures. Reuse the funded *Stellar* account, not the contract instance. + +--- + +## Ethics on a Shared Deployment + +The testnet deployment is shared. This helper creates state, so: + +- **Never call `upgrade()`.** Admin-gated and destructive for every integrator. +- **Never call `update_authorized_destination()`.** Creator-gated and it changes routing for every account on that controller. +- **Do not sweep or expire the long-lived accounts** in `testnet/registry/known-test-accounts.md`. +- **Fund only your own accounts.** +- **Prefer short expiries** so your accounts retire rather than accumulating. +- Do not add a batch mode. `AccountFactory::batch_initialize` exists for programmatic fan-out and **swallows per-account error detail** (`success: false, error: None`) — see `testnet/runbooks/account-factory-batch-failure.md`. A one-account helper that is obviously correct is more useful than a batch helper that fails invisibly. + +The broader set of shared-state hazards is in `testnet/security/known-testnet-abuse-patterns.md`. + +--- + +## Open Questions + +- Should it emit a Host.bot payment-request URL instead of calling Friendbot directly? Friendbot's canonical UX is the URL, and it avoids rate-limit interactions entirely. +- Should `--recovery` be mandatory with no default? The default is convenient; requiring it is safer. Lean toward mandatory, since the whole point is eliminating silent misconfiguration. +- Should the helper verify the WASM hash it is about to deploy against `testnet/registry/wasm-hash-reference.md`? +- Worth a `--json` schema stable enough to depend on from CI? If so, it needs a version field. + +--- + +## Related Documentation + +- `testnet/config/friendbot.md` — funding behaviour and rate limits +- `testnet/config/expiry-ledger-testing.md` — expiry arithmetic, and why not to use a clock +- `testnet/config/soroban-cli-identity-setup.md` — identity conventions used here +- `testnet/registry/known-test-accounts.md` — long-lived accounts not to disturb +- `testnet/runbooks/account-factory-batch-failure.md` — why batch creation is not wrapped +- `testnet/examples/create-and-fund-ephemeral-account.md` — the manual flow this replaces +- `docs/api-reference.md` — `initialize` parameter reference + +--- + +## Version History + +| Version | Date | Changes | +|---|---|---| +| 1.0 | 2026-09-26 | Initial faucet helper spec | diff --git a/testnet/tooling/nonce-inspector-spec.md b/testnet/tooling/nonce-inspector-spec.md new file mode 100644 index 0000000..17e0572 --- /dev/null +++ b/testnet/tooling/nonce-inspector-spec.md @@ -0,0 +1,218 @@ +# Spec: Nonce Inspector — Read the Current `SweepController` Nonce + +## Status + +**Spec only. Nothing in this document is implemented.** See `testnet/tooling/decision-log.md` for why this directory holds specifications rather than runnable scripts. + +## Problem + +`SweepController` verifies every sweep signature against `SHA256(destination ‖ nonce ‖ controller_id)` using its **own current on-chain nonce**. Off-chain signers that track the nonce locally will eventually disagree with the contract, and the symptom is a bare host-function trap from `ed25519_verify` — no error code, no diagnostics, nothing to match on. + +That failure mode is documented in `testnet/runbooks/failed-sweep-signature.md`, and it is the single most common cause of a rejected sweep signature. The workaround today is a shell one-liner that has been copy-pasted into at least three places in this repo. + +A read-only helper that prints the current nonce — and, ideally, enough context to tell whether a signature you already hold is still valid — removes the highest-frequency source of confusing testnet failures. + +> This tool **directly supports** the workflow in `testnet/config/nonce-tracking.md`. That document is the reference; this is a proposal for tooling around it. + +## Scope + +**In scope:** read-only. Query the nonce, show it, and report whether a given signature is still expected to verify. + +**Explicitly out of scope:** + +- Signing. That is `tools/sweep-signer`, which already exists and works. +- Submitting transactions of any kind. +- Mutating anything, ever. +- Predicting or setting the nonce. + +A read-only tool cannot grief the shared deployment, cannot consume a nonce, and cannot interfere with anyone's sweep. That property is the entire design constraint. + +--- + +## Interface + +### Invocation + +```bash +bridgelet-nonce --controller [options] +``` + +### Options + +| Option | Default | Purpose | +|---|---|---| +| `--controller ` | required | `SweepController` contract ID. Resolve names via the contract-ID lookup tool spec. | +| `--json` | off | Emit machine-readable JSON for CI. | +| `--check ` | off | Verify a signature you already hold against the current nonce. | +| `--destination ` | none | Destination used with `--check`. | +| `--rounds ` | `3` | How many times to sample, to detect churn. | +| `--rpc-url ` | `https://soroban-testnet.stellar.org` | RPC endpoint. | +| `--timeout-ms ` | `10000` | Per-request timeout. | + +### Output — human + +``` +controller: CBEU4X5MNGOECBSTNEUFMBALH2YI5YV4UIH7YXRNOLR2DNLZQD4Z5KWE +network: testnet +nonce: 7 +stable: yes (3 samples, all 7) +next: sign with --nonce 7, then submit promptly +``` + +### Output — JSON + +```json +{ + "controller": "CBEU4X5MNGOECBSTNEUFMBALH2YI5YV4UIH7YXRNOLR2DNLZQD4Z5KWE", + "network": "testnet", + "nonce": 7, + "stable": true, + "samples": [7, 7, 7], + "checked_signature": null +} +``` + +With `--check`: + +```json +{ + "controller": "CBEU...", + "nonce": 7, + "stable": true, + "checked_signature": { + "destination": "GBEST...", + "nonce_used": 6, + "expected": false, + "reason": "stale-nonce", + "message": "signature was built over nonce 6, controller is at 7; re-sign with --nonce 7" + } +} +``` + +### Exit codes + +| Code | Meaning | +|---|---| +| `0` | Success. `expected: true` if `--check` was given. | +| `1` | Signature check failed / signature is stale. | +| `2` | Usage error (bad ID, missing required option). | +| `3` | RPC unreachable or timed out. | +| `4` | Controller not initialised, or not a contract. | + +Distinct exit codes matter: CI needs to tell "your signature is stale, retry" (1) from "the network is down" (3). Collapsing them teaches people to ignore the failure. + +--- + +## Behaviour + +### Core operation + +One `simulateTransaction` invoking `get_nonce()`, or a plain read-only `invokeHostFunction`. The SDK exposes the nonce as `u64`; it is not a `u128`, so **JSON output must carry it as a number or a decimal string, never as a float.** + +### `stable` — the part that makes this worth building + +A single sample is not enough to be useful. Between your read and your submission the nonce can move. + +Sample `--rounds` times with a short interval and report whether they agree: + +- All equal → `stable: true`. The value is probably still current. +- Any differ → `stable: false`, and print **all** samples plus the delta. The nonce is moving under you; serialise your signing and retry. + +This turns an invisible race into an explicit signal, and it is cheap — a handful of read-only RPC calls. + +### `--check` — verify a signature you already hold + +Reconstruct the digest the contract would compute, and compare the signature against it: + +``` +digest = SHA256( destination.to_xdr() || nonce_be_u64(current) || controller_id.to_xdr() ) +``` + +Then report whether `auth_signature` verifies against `digest` under the controller's `authorized_signer`. + +Two implementation notes: + +1. **You need the controller's `authorized_signer` to verify.** There is **no getter** for it on `SweepController`. Read it from `deployments/testnet.json` (`config.authorizedSigner`) and say so in the output — if the config file is stale relative to the deployment, the answer will be wrong and the user needs to know which input it came from. +2. **Do not hand-roll `Address::to_xdr()`.** Use `soroban-sdk`'s own serialisation, exactly as `tools/sweep-signer` does. A hand-rolled XDR encoder is the failure mode `docs/SIGNATURE_FORMAT.md` warns about at length: subtly wrong bytes produce a signature that does not verify, with no indication of why. + +A useful extra: also report whether the signature verifies against the **previous** nonce, to distinguish "definitely stale" from "never was valid". + +--- + +## Non-Goals + +| Not doing | Why | +|---|---| +| Tracking the nonce across invocations | The whole point is that a local counter drifts. Storing one invites the bug. | +| Caching | A cached nonce is the failure being fixed. | +| Any write path | Keeps the tool incapable of griefing the shared deployment. | +| Submitting sweeps | Belongs in a relayer, not in a diagnostic. | +| Validating ephemeral account state | `simulate_sweep()` and `can_sweep()` already cover that; don't duplicate. | + +--- + +## Integration + +### Before signing + +```bash +NONCE=$(bridgelet-nonce --controller "$SWEEP_CONTROLLER_ID" --json | jq -r '.nonce') +# ... sign with $NONCE ... +``` + +### In CI, gate on stability + +```bash +bridgelet-nonce --controller "$SWEEP_CONTROLLER_ID" --rounds 5 --json +# non-zero exit = nonce is moving or RPC is down; do not sign +``` + +### With `tools/sweep-signer` + +```bash +SIG=$(cargo run --manifest-path tools/sweep-signer/Cargo.toml -- sign \ + --contract-id "$SWEEP_CONTROLLER_ID" --destination "$DEST" \ + --nonce "$(bridgelet-nonce --controller "$SWEEP_CONTROLLER_ID" --json | jq -r .nonce)" \ + --signer-seed-hex "$SEED" | grep ^auth_signature | awk '{print $NF}') + +# Confirm it is still expected to verify BEFORE submitting +bridgelet-nonce --controller "$SWEEP_CONTROLLER_ID" \ + --check "$SIG" --destination "$DEST" || echo "re-sign and retry" +``` + +That last step is the real win: a cheap local check that catches a stale signature **before** paying a fee to have it rejected on-chain. + +### As a test assertion + +```typescript +// A sweep helper should not submit against a nonce it assumed was current. +const { nonce, stable } = await nonceInspector(controllerId); +if (!stable) throw new Error('nonce in flux; serialise and retry'); +``` + +--- + +## Open Questions + +- Should `--check` be able to read `authorized_signer` from somewhere more authoritative than `deployments/testnet.json`? There is no on-chain getter. A proposal to add one would be a contract change, outside this directory's scope. +- Poll interval between `--rounds` samples. Too short and consecutive reads land in the same ledger (wasted calls, false confidence); too long and the check becomes slow. Probably one or two ledgers. +- Should the tool warn when it detects a locked `authorized_destination`? Also not readable on-chain, but inferable from `UnauthorizedDestination` behaviour. + +--- + +## Related Documentation + +- `testnet/config/nonce-tracking.md` — the workflow this supports; nonce semantics and pitfalls +- `testnet/runbooks/failed-sweep-signature.md` — what to do when a signature is rejected +- `docs/SIGNATURE_FORMAT.md` — message format, and the XDR-encoding warning +- `tools/sweep-signer/` — the existing signing tool (referenced, not modified here) +- `testnet/registry/verify-contract-live.md` — health checks, of which this is one +- `testnet/tooling/decision-log.md` — why this is a spec and not a script + +--- + +## Version History + +| Version | Date | Changes | +|---|---|---| +| 1.0 | 2026-09-26 | Initial nonce inspector spec |