Skip to content

docs(testnet): pre-flight checks, error mapping, idempotency guide, and Postman collection - #752

Merged
phertyameen merged 1 commit into
bridgelet-org:mainfrom
brightMedina5050:docs/testnet-examples-and-error-mapping
Sep 27, 2026
Merged

phertyameen merged 1 commit into
bridgelet-org:mainfrom
brightMedina5050:docs/testnet-examples-and-error-mapping

Conversation

@brightMedina5050

Copy link
Copy Markdown
Contributor

Summary

Four integrator-facing deliverables for the shared testnet deployment: two worked examples, a full error mapping, an idempotency guide, and a Postman collection.

File Issue Lines
testnet/examples/checking-account-status.md #638 420
testnet/examples/error-handling.md #639 709
testnet/examples/idempotency-considerations.md #640 499
testnet/examples/postman-collection.json #641 1060

Plus one additive row per deliverable in the ## Example index table in testnet/examples/README.md.

All four are derived from the contract source, because no Bridgelet contract is currently deployed — testnet/registry/contract-status.md records all four as undeployed. Nothing claims on-chain observation. The idempotency guide's issue explicitly asked for behaviour "from observed testnet behavior", and the honest answer is that it has not been observed; the document derives it from source, says so, and lists what a live test would need to confirm.

checking-account-status.md — pre-flight checks

The document's backbone is the insight that both checks mislead for an uninitialized account, and the failure modes differ:

  • is_expired() returns false when the instance is not initialized — so a false is not proof the account is live, and false is also exactly what a healthy account returns. get_status() has the same trap, returning Active for an uninitialized instance.
  • can_sweep() is a SweepController method that takes the child account address and calls get_info() internally; get_info() returns Result<AccountInfo, Error>, so an uninitialized account is an error, not a false. A bare false collapses three distinct causes.

So the example is a gate, not two calls: is_expired() for the cheap time check, can_sweep() for composite readiness, and get_info() for the initialization proof neither of the other two gives. A "what each check proves and what it does not" table makes that explicit, building on the sibling README's existing warning that get_status() is not proof of initialization.

The >= boundary gets explicit treatment because is_expired() compares current_ledger >= expiry_ledger — an account expiring this ledger already reports expired, which is precisely where fee-wasting races happen. The fee argument carries an honest limit: a successful simulation is not a reservation, so this narrows failure modes rather than eliminating them. Every probe uses --simulate-only, so it costs nothing and mutates nothing — which is the entire point of a pre-flight.

error-handling.md — variant → handling

Every variant in all four contracts mapped to discriminant, triggering condition, and what an integrator should do (retry / re-fetch state and re-sign / stop and escalate). That last column is the deliverable. Rust throughout, matching the SDK examples in docs/api-reference.md and testnet/integration/type-mapping.md. Cross-referenced to docs/api-reference.md as its companion, not a second source of truth, per the issue.

It also covers errors that are not enum variants, because a document mapping only enums would mislead anyone who only ever sees host-function traps — and on this codebase that is most people:

  • A bare ed25519_verify trap is usually nonce contention, not a signing bug.
  • The signature asymmetry: SweepController::verify_sweep_auth performs real Ed25519 verification, while EphemeralAccount::verify_sweep_authorization ignores its auth_signature entirely — so the two InvalidSignature variants mean different things.

In the sample code, NonEnum is kept distinct from every enum variant so a host trap can never be decoded as a contract code.

idempotency-considerations.md — duplicate sweeps

The signature covers SHA256(destination.to_xdr() ‖ nonce as 8-byte big-endian u64 ‖ controller_id.to_xdr()), and the nonce is incremented only after successful authorization, so a replayed signature is bound to a consumed nonce.

The valuable part is a distinction the issue needs in order to be useful at all: retrying the same signature and re-signing against a fresh nonce have completely different outcomes, and only one of them is a no-op. Re-signing with the old nonce produces a signature the contract will reject. claim() is a different animal again — it takes no signature, so a duplicate claim is a state question rather than a signature question, and conflating them would mislead anyone writing retry logic.

A decision table ("did I get a result" / "is the state consistent" / "what may I safely do next") is the actual deliverable, alongside the replay-proof hazards that are state-guarded rather than nonce-guarded.

postman-collection.json — machine-readable

A Postman Collection v2.1 artifact (verified to parse, with the v2.1.0 schema URL), folder-per-contract, 21 collection variables, and a saved response example on all 14 requests so the collection is useful before anyone runs it.

Two design decisions worth stating:

  • The simulateTransaction envelope is a collection variable with a documented build-it-yourself recipe, not a committed literal. Soroban RPC has no convenience contract-call endpoint, so a real base64 TransactionEnvelope cannot be hand-written — and faking one would be a fabrication. Every request requires an inactive or read-only source, so no request in the collection can change state, which is also why none of them needs a key.
  • The collection uses the Testnet passphrase and says so, since testnet/docs/changelog.md records that the check currently expects Standalone and would reject testnet calls.

Code/document defects found, documented rather than papered over

The two I already knew (the changelog's error-namespacing claim, and UnauthorizedDestination = 13 leaving a gap at 12) are covered explicitly. This batch surfaced several more:

  1. claim does not increment the nonce. get_nonce's own doc comment and testnet/config/nonce-tracking.md both say the nonce "increments after every successful execute_sweep()/claim()" — but claim calls authorize_claim directly and never reaches sweep_account, which holds the only increment_nonce call. This is the highest-value find in the batch: it breaks any "count the sweeps" reconciliation an integrator might attempt. Flagged as needing a live test.
  2. Six SweepController error variants are unreachable. ed25519_verify traps, so there is no Err to convert. InvalidAccount(1), InsufficientBalance(4), AccountExpired(6), InvalidSignature(8), SignatureVerificationFailed(9) and InvalidNonce(11) are never constructed — yet docs/api-reference.md and testnet/runbooks/failed-sweep-signature.md both list Create API Reference Documentation #9 as the expected bad-signature symptom. The same pattern appears on the other two contracts.
  3. record_payment returns DuplicateAsset(13), never PaymentAlreadyReceived(3). The rule is one payment per asset (up to 10), not one per account.
  4. The namespacing claim is repeated with different arithmetic. testnet/docs/getting-started.md lists #1000/#1001/#1012… — the source codes plus a flat +999 — and maps #1007 → Unauthorized, while the changelog cites #1007 as the passphrase rejection. Two meanings, one number, two files.
  5. Error(Contract, #1007) cannot happen anyway — the initialize passphrase check the changelog describes is not in the source.
  6. testnet/docs/soroban-cli-quickref.md shows can_sweep --account <EPH>; the parameter is ephemeral_account.
  7. docs/api-reference.md's EphemeralAccount error table stops at code 14, omitting NotUpgradeAdmin(15).
  8. bridgelet_shared::passphrase::TESTNET_PASSPHRASE does not exist (contracts/shared/src/ has only lib.rs and types.rs), though testnet/docs/network-config.md advertises it. The passphrase is also given as November 2015 in testnet/integration/type-mapping.md against September 2015 in network-config.md.
  9. AccountFactory has no errors.rs at all, so "mapping its error variants" means something different — handled explicitly rather than silently omitted, including that initialize's .unwrap() on the WASM hash panics rather than returning an error.

Each is documented in the relevant file rather than fixed, since fixing them means editing files outside this batch's scope.

Verification

  • scripts/, tools/, contracts/, docs/, deployments/ untouched.
  • The testnet/examples/README.md change is 4 insertions, 0 deletions.
  • postman-collection.json parses as strict JSON with the v2.1.0 schema; 6 folders, 14 requests, 15 saved responses.
  • All relative links ls-verified. No secret material, including inside the JSON.

Closes #638
Closes #639
Closes #640
Closes #641

…idempotency, and a Postman collection

Four integrator-facing deliverables for the shared testnet deployment. All
four are derived from the contract source, because no Bridgelet contract is
currently deployed: testnet/registry/contract-status.md records all four as
undeployed with IDs "TBD" and Last Verified "Never". Nothing below claims to
have been observed on-chain, and each document says so explicitly, because
the issue for the idempotency guide asked for behaviour "from observed
testnet behavior" and the honest answer is that it has not been observed yet
and a live test is required to confirm it.

testnet/examples/checking-account-status.md

is_expired() and can_sweep() as pre-flight checks, built around the insight
that both mislead for an uninitialized account and the failure modes differ:

- is_expired() returns false when the instance is not initialized, so a
  false is not proof the account is live - and false is also exactly what a
  healthy account returns. get_status() has the same trap, returning Active
  for an uninitialized instance.
- can_sweep() is a SweepController method that takes the child account
  address and calls get_info() internally, and get_info() returns
  Result<AccountInfo, Error>, so an uninitialized account is an error rather
  than a false. A bare false from can_sweep() collapses three causes.

So the example is a gate rather than two calls: is_expired() for the cheap
time check, can_sweep() for composite readiness, and get_info() for the
initialization proof neither of the other two gives. A "what each check proves
and does not prove" table makes that explicit, matching the sibling README's
existing warning that get_status() is not proof of initialization.

The >= boundary gets explicit treatment, because is_expired() compares
current_ledger >= expiry_ledger, so an account expiring this ledger already
reports expired - which is exactly where fee-wasting races happen. The fee
argument carries an honest limit: a successful simulation is not a
reservation, so this narrows failure modes rather than eliminating them. The
CLI flag is --simulate-only, so every probe costs nothing and mutates
nothing.

testnet/examples/error-handling.md

Every error variant in all four contracts mapped to a discriminant, the
condition that fires it, and what an integrator should actually do - retry,
re-fetch state and re-sign, or stop and escalate. The "what to do" column is
the deliverable. Cross-referenced to docs/api-reference.md as the
companion, not a second source of truth, per the issue. Rust throughout,
matching the SDK examples in docs/api-reference.md and
testnet/integration/type-mapping.md.

It also covers errors that are not enum variants, because a document mapping
only enums would mislead anyone who only ever sees host-function traps - and
on this codebase that is most people:

- A bare ed25519_verify trap is usually nonce contention, not a signing
  bug, per testnet/runbooks/failed-sweep-signature.md and
  testnet/security/README.md.
- The signature asymmetry: SweepController::verify_sweep_auth performs real
  Ed25519 verification, while EphemeralAccount::verify_sweep_authorization
  ignores its auth_signature entirely, so the two InvalidSignature variants
  mean different things.

NonEnum is kept distinct from every enum variant in the sample code so a
host trap can never be decoded as a contract code.

testnet/examples/idempotency-considerations.md

What a duplicate sweep submission does, worked out from the source and
labelled as needing live confirmation. The signature covers
SHA256(destination.to_xdr() || nonce as 8-byte big-endian u64 ||
controller_id.to_xdr()), and the nonce is incremented only after successful
authorization, so a replayed signature is bound to a consumed nonce.

The valuable part is the distinction the issue needs in order to be useful:
retrying the same signature and re-signing against a fresh nonce have
completely different outcomes, and only one of them is a no-op. Re-signing
with the old nonce produces a signature the contract will reject. claim() is
a different animal again - it takes no signature, so a duplicate claim is a
state question rather than a signature question, and conflating the two
would mislead anyone writing retry logic.

A decision table ("did I get a result" / "is the state consistent" / "what
may I safely do next") is the actual deliverable, plus replay-proof
hazards that are state-guarded rather than nonce-guarded.

testnet/examples/postman-collection.json

A Postman Collection v2.1 artifact - the only machine-readable deliverable
in this batch - with folder-per-contract structure, 21 collection variables,
and a saved response example on every one of the 14 requests so the
collection is useful before anyone runs it.

The simulateTransaction envelope is a collection variable with a documented
build-it-yourself recipe rather than a committed literal: Soroban RPC has no
convenience contract-call endpoint, so a real base64 TransactionEnvelope
cannot be hand-written, and faking one would be a fabrication. Every request
requires an inactive or read-only source, so no request in the collection
can change state - which is also why none needs a key. The collection uses
the Testnet passphrase and says so, since testnet/docs/changelog.md records
that the passphrase check currently expects Standalone and would reject
testnet calls.

Also adds one row per deliverable to the ## Example index table in
testnet/examples/README.md, which is what that table is for.

Closes bridgelet-org#638
Closes bridgelet-org#639
Closes bridgelet-org#640
Closes bridgelet-org#641
@drips-wave

drips-wave Bot commented Sep 27, 2026

Copy link
Copy Markdown

@brightMedina5050 Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@phertyameen
phertyameen merged commit cbb1369 into bridgelet-org:main Sep 27, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment