From e78fbdd9b48401cd15725d46e754999d1f2e3ae0 Mon Sep 17 00:00:00 2001 From: temiport25 <261153140+temiport25@users.noreply.github.com> Date: Thu, 15 Oct 2026 19:01:59 +0100 Subject: [PATCH] docs(testnet): testing against upgraded contracts and mock vs live tradeoffs --- .../local-mock-vs-live-testnet-tradeoffs.md | 186 +++++++++++++++ .../testing-against-upgraded-contracts.md | 211 ++++++++++++++++++ 2 files changed, 397 insertions(+) create mode 100644 testnet/integration/local-mock-vs-live-testnet-tradeoffs.md create mode 100644 testnet/integration/testing-against-upgraded-contracts.md diff --git a/testnet/integration/local-mock-vs-live-testnet-tradeoffs.md b/testnet/integration/local-mock-vs-live-testnet-tradeoffs.md new file mode 100644 index 0000000..83cf650 --- /dev/null +++ b/testnet/integration/local-mock-vs-live-testnet-tradeoffs.md @@ -0,0 +1,186 @@ +# Local Mock or Live Testnet? Choosing How to Test Your Integration + +## Purpose + +There is no ready-made local entry point for exercising Bridgelet's contracts +from an integrator's own code. A team writing an integration has to decide +between **building a local mock of the contracts' behaviour** (fast, offline) and +**always testing against the live testnet deployment** (faithful, slow, shared). + +This page lays out the trade-off so the choice is made on purpose. It also says +what a mock must reproduce to be worth having, because several contract +behaviours are surprising enough that a naive mock would make tests pass that +fail for real. + +## Status + +**Reasoned from the contract source and existing documents; not observed.** No +contract is recorded as deployed (`../registry/contract-status.md`), so the +live-testnet side of the comparison is described from the harness notes and not +from experience. + +## Scope + +This is about testing **your application's** use of the contracts. Testing the +contracts themselves is a separate job, done in-process with `cargo test`. + +| Layer | Tool | Who it is for | +|---|---|---| +| Contract logic | `cargo test` with the Soroban test environment (`scripts/test.sh`) | People changing the contracts | +| Your code against a mock of the contracts | A mock you write | Integrators, for fast feedback | +| Your code against a real deployment | Live testnet | Integrators, for fidelity | + +The repository's own `docs/testing.md` also describes starting a local sandbox +container. Another document in this tree says the repository has no local +sandbox. These disagree and neither has been verified here, so this page does +not depend on a sandbox existing. + +## The trade-off + +| | Local mock | Live testnet | +|---|---|---| +| Speed | Milliseconds | 5 to 15 seconds per state-changing step | +| Determinism | Total | Shared state, other users, network variance | +| Works offline | Yes | No | +| Cost and limits | None | Friendbot and RPC rate limits | +| Fidelity | Only as good as the mock | The real thing | +| Catches contract drift | **No**, unless you update the mock | Yes | +| Catches nonce contention | No | **Only here** | +| Catches wrong network config | No | Yes | +| Maintenance | You own the mock | None, but the environment changes under you | +| Failure cause is clear | Yes | Often not (could be someone else's activity) | + +Neither dominates. A mock buys speed and determinism and pays for it in +fidelity; live testnet is the reverse. + +## What a mock must get right + +The danger of a mock is not that it is simple, it is that it is **plausible and +wrong**. These behaviours are the ones a straightforward implementation gets +wrong. Each is taken from the source and the existing error documentation. + +| Behaviour | A naive mock does | The contracts actually do | +|---|---|---| +| A bad sweep signature | Returns a named error | **Traps** with no error code (`ed25519_verify` aborts) | +| `EphemeralAccount::sweep()` signature | Validates it | **Ignores it**; authorisation is the stored controller's `require_auth()` | +| Nonce | One per account | **One per controller**, advanced by every successful sweep | +| Error codes | Globally unique | Scoped to the contract; the same number means different things | +| Second payment, same asset | Adds to the first | Rejected with `DuplicateAsset` (13) | +| Payments per account | Unlimited | At most 10 distinct assets, then `TooManyPayments` | +| `record_payment` | Moves or checks funds | Only stores an entry | +| Expiry | Expires on its own | Nothing happens at `expiry_ledger`; someone must call `expire()` | +| `batch_initialize` failure | Returns a reason | Returns `success: false` and no reason | +| Destination change | Always allowed | Rejected once the nonce is above zero | +| Time | Wall clock | Ledger sequence; roughly five seconds per ledger | + +A mock that gets these right is a small state machine. One that does not will +teach your code to handle errors that never occur and miss the ones that do. + +### A minimal shape + +```typescript +type Status = 'Active' | 'PaymentReceived' | 'Swept' | 'Expired'; + +class MockEphemeralAccount { + status: Status = 'Active'; + payments = new Map(); // one entry per asset, max 10 + + constructor(readonly expiryLedger: number, private clock: { ledger: number }) {} + + recordPayment(asset: string, amount: bigint) { + if (amount <= 0n) throw contractError('EphemeralAccount', 4); // InvalidAmount + if (this.payments.has(asset)) throw contractError('EphemeralAccount', 13); // DuplicateAsset + if (this.payments.size >= 10) throw contractError('EphemeralAccount', 14); // TooManyPayments + this.payments.set(asset, amount); + if (this.status === 'Active') this.status = 'PaymentReceived'; + } + + isExpired() { + return this.clock.ledger > this.expiryLedger; // true does NOT change status + } +} +``` + +`contractError` should carry the `(contract, code)` pair, never a bare integer. +Model a rejected signature as a thrown trap that has no code, so your error +handling is forced to deal with it. + +## Keeping a mock honest + +A mock drifts the moment a contract changes. Three habits keep it useful: + +1. **Run the same scenarios against both.** Write each test as a scenario that + can target the mock or the live deployment. When a scenario passes on the mock + and fails live, the mock is wrong and is corrected. +2. **Pin what the mock was written against.** Record the contract version and + WASM hash it models. See + [`testing-against-upgraded-contracts.md`](testing-against-upgraded-contracts.md). +3. **Keep it thin.** Mock the contract's state machine and error codes, not the + network. The more of Stellar you reimplement, the more there is to get wrong. + +## What never to mock + +| Thing | Why | +|---|---| +| The sweep signature bytes | The digest is `SHA256(destination || nonce || controller_id)` and the encoding is easy to get subtly wrong. Test signing against the real signer tool | +| The global nonce under contention | The behaviour is a property of the shared deployment | +| Network passphrase and endpoint config | A wrong value fails only against the real network | + +## A practical split + +For most teams a hybrid beats either extreme: + +| Suite | Runs | Target | Purpose | +|---|---|---|---| +| Unit and component tests | Every commit | Mock | Fast feedback on your logic and error handling | +| Contract-parity scenarios | On demand, and before releases | Mock **and** live | Detect mock drift | +| Smoke test | Nightly or before release | Live testnet | Prove the deployment is usable | + +Keep the live suite **small**. It exists to confirm that the shared deployment +works and that your configuration is right, not to re-test your logic slowly. +The harness design for that suite is in +[`../examples/integration-test-harness-notes.md`](../examples/integration-test-harness-notes.md). + +## When to choose which + +| Situation | Lean toward | +|---|---| +| Early development, changing code constantly | Mock | +| Testing error handling for every contract error | Mock (you can force each one) | +| Verifying you can actually create, pay and sweep | Live testnet | +| CI that must be fast and never flaky | Mock for gating, live as a non-blocking check | +| Anything involving signatures or nonces | Live testnet | +| No contracts deployed yet | Mock is the only option | + +The last row is the current situation: with no deployment recorded, a mock is +the only thing available, which makes keeping it honest more important, not +less. + +## Undecided + +| Question | State | +|---|---| +| Whether this project should publish a reference mock | Not proposed; `../tooling/` holds specifications only | +| Whether a local sandbox container is a supported option | `docs/testing.md` describes one; its status is unverified here | +| How closely a mock should model ledger and fee behaviour | Left to the integrator | + +## Related Documentation + +- [`../examples/integration-test-harness-notes.md`](../examples/integration-test-harness-notes.md): + designing a live-testnet suite +- [`testing-against-upgraded-contracts.md`](testing-against-upgraded-contracts.md): + pinning what a mock or a suite was written against +- [`../examples/error-handling.md`](../examples/error-handling.md): the error + behaviour a mock must reproduce +- [`../examples/idempotency-considerations.md`](../examples/idempotency-considerations.md): + nonce and replay behaviour +- [`../../docs/testing.md`](../../docs/testing.md): testing the contracts + themselves +- [`../registry/contract-status.md`](../registry/contract-status.md): whether + there is anything live to test against + +## Version History + +| Version | Date | Changes | +|---|---|---| +| 1.0 | 2026-09-26 | Initial trade-off guide for mock versus live-testnet testing | diff --git a/testnet/integration/testing-against-upgraded-contracts.md b/testnet/integration/testing-against-upgraded-contracts.md new file mode 100644 index 0000000..75a36dc --- /dev/null +++ b/testnet/integration/testing-against-upgraded-contracts.md @@ -0,0 +1,211 @@ +# Testing Against Contracts That May Be Upgraded Mid-Run + +## Purpose + +`EphemeralAccount::upgrade(new_wasm_hash)` replaces a contract's code in place. +The contract ID does not change, nothing is emitted, and the next call simply +runs different code. If that happens while your test suite is running, tests +that passed a minute ago start failing for reasons that look like your own bugs. + +This page describes how a consuming project's test suite should **pin the +expected WASM hash, check it at startup, and fail fast with a clear message**, +instead of producing confusing downstream failures. + +## Status + +**Reasoned from the contract source and the upgrade runbook; not observed.** No +contract is recorded as deployed (`../registry/contract-status.md`) and no +upgrade has been recorded (`../registry/upgrade-history.md`). The CLI commands +below are those named in `../runbooks/upgrade-verification.md`; support varies by +CLI version and none has been run against a live deployment. + +## What can change underneath you + +| Event | Contract ID | State | Signal you get | Covered here | +|---|---|---|---|---| +| In-place `upgrade()` | Same | Kept | **None.** No event is emitted | Yes | +| Redeploy | **New** | **Destroyed** | Old ID stops resolving | Partly; see below | +| SDF testnet reset | Gone | Destroyed | Ledger sequence goes backwards | Partly; see below | + +Only `EphemeralAccount` has an `upgrade()` function in the current source. +`SweepController`, `ReserveContract` and `AccountFactory` have none. An upgrade +is gated by the `admin` stored at `initialize()`, so it affects instances whose +admin key was used, and it changes only the instance it is called on, not the +others. + +The danger is the silence: `upgrade()` emits no event and has no timelock, so +the only evidence is the transaction history and the contract's current code +hash. + +## The check: pin a hash, verify at startup + +### 1. Record the hash you tested against + +When you first get the suite green against a verified deployment, record the +deployed WASM hash and keep it in version control, next to the contract ID: + +```json +{ + "network": "testnet", + "contracts": { + "ephemeralAccount": { + "id": "", + "wasmHash": "<64-hex-character-hash>" + } + } +} +``` + +A hash is public information, so committing it is safe. Never put a secret key, +seed or credential in this file. + +### 2. Read the live hash + +```bash +stellar contract info hash \ + --contract-id \ + --network testnet +``` + +`contract info hash` is not available in every CLI version. If yours lacks it, +use the SDK or RPC to read the contract's executable from its ledger entry. Do +not guess a ledger key, and do not substitute the hash of a local build: that is +the hash of what you *built*, not of what is *deployed*. + +### 3. Compare, and stop on a mismatch + +```bash +#!/usr/bin/env bash +set -euo pipefail + +EXPECTED="$(jq -r '.contracts.ephemeralAccount.wasmHash' pinned-contracts.json)" +CONTRACT_ID="$(jq -r '.contracts.ephemeralAccount.id' pinned-contracts.json)" + +ACTUAL="$(stellar contract info hash --contract-id "$CONTRACT_ID" --network testnet)" + +if [ "$ACTUAL" != "$EXPECTED" ]; then + cat >&2 < \ + --network testnet +``` + +Compare it against the interface your suite was written for. An unchanged +interface with a changed hash can still mean changed behaviour, so keep the hash +check; a changed interface is a stronger signal still. + +## Redeploys and resets + +These look different from an upgrade, and the same startup step should handle +them: + +- **A redeploy** gives new contract IDs. The old ID stops resolving, and the + read above fails rather than returning a different hash. Treat "contract not + found" at startup as its own clear message (see + [`../runbooks/contract-not-found-error.md`](../runbooks/contract-not-found-error.md)). +- **An SDF reset** wipes everything. The network's latest ledger is lower than + one you recorded. Store the ledger you last ran against and compare. + +See [`../policies/reset-policy.md`](../policies/reset-policy.md) for what each +destroys. + +## What not to do + +| Mistake | Why it hurts | +|---|---| +| Pin only the contract ID | It does not change on an upgrade, so the check passes while the code differs | +| Compare against a locally built WASM's hash | That is your build, not the deployment | +| Subscribe to an "upgraded" event | None is emitted | +| Overwrite the pin automatically | It accepts any change as correct | +| Keep running after a mismatch | Every later result is about unknown code | + +## Undecided + +| Question | State | +|---|---| +| Whether `contract info hash` is available in the pinned CLI version | Needs checking against the version in use | +| Whether this project should publish a machine-readable pin file | Not proposed; the registry holds the hashes in Markdown | +| How quickly an upgrade is announced | No notice commitment exists; see the reset policy | + +## Related Documentation + +- [`../runbooks/upgrade-verification.md`](../runbooks/upgrade-verification.md): + verifying an upgrade, including the code-hash commands used here +- [`../registry/upgrade-history.md`](../registry/upgrade-history.md): the log of + recorded upgrades, currently empty +- [`../registry/wasm-hash-reference.md`](../registry/wasm-hash-reference.md): the + hash each contract is recorded as running +- [`../examples/integration-test-harness-notes.md`](../examples/integration-test-harness-notes.md): + the rest of a live-testnet suite's design +- [`../security/admin-key-hygiene.md`](../security/admin-key-hygiene.md): who can + call `upgrade()`, and why it is silent +- [`../policies/reset-policy.md`](../policies/reset-policy.md): redeploys and + resets + +## Version History + +| Version | Date | Changes | +|---|---|---| +| 1.0 | 2026-09-26 | Initial guidance for pinning and checking the WASM hash in a test suite |