Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
186 changes: 186 additions & 0 deletions testnet/integration/local-mock-vs-live-testnet-tradeoffs.md
Original file line number Diff line number Diff line change
@@ -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<string, bigint>(); // 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 |
211 changes: 211 additions & 0 deletions testnet/integration/testing-against-upgraded-contracts.md
Original file line number Diff line number Diff line change
@@ -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": "<EPHEMERAL_ACCOUNT_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 <EPHEMERAL_ACCOUNT_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 <<MSG
Contract code changed: this test run is invalid.
contract: $CONTRACT_ID
pinned: $EXPECTED
deployed: $ACTUAL
The contract was probably upgraded in place. Review the change, run the
upgrade-verification checks, and update pinned-contracts.json in a reviewed
commit. Do not re-run the suite until you have.
MSG
exit 3
fi
```

Run this **before** the first test and make a non-zero exit stop the whole run.
Use a distinct exit code so CI can report "contract changed" separately from
"tests failed".

## Failing clearly

A good failure message answers four questions without anyone opening a log:

| Question | Answer in the message |
|---|---|
| What changed? | The contract ID, the pinned hash and the deployed hash |
| Is it my code? | No: the dependency changed |
| What should I do? | Review the upgrade, then update the pin deliberately |
| Is the run usable? | No: results are void, do not read them |

Report it as an **environment failure**, not a test failure, so a flaky-test
dashboard does not absorb it.

## Do not auto-update the pin

The tempting shortcut is to have the suite overwrite the pin when it differs.
That turns the check into a no-op: it would accept a malicious or accidental
upgrade as the new normal. Updating the pin should be a human decision recorded
in a commit, after comparing the new interface and behaviour with what your tests
assume.

## Upgrades during a run

A startup check proves the code was right when the run began. It does not prove
nothing changed since. For a long suite:

| Technique | Cost | Catches |
|---|---|---|
| Re-check the hash at the end of the run | One read | An upgrade at any point during the run, reported after the fact |
| Re-check between test files | One read per file | An upgrade within one file's window |
| Re-check on any unexpected failure | One read, on failure only | Distinguishes "contract changed" from "real bug" at the moment it matters |

The third is the cheapest useful one: when a test fails in a way you do not
recognise, read the hash before you spend an hour debugging. If it differs, the
failure is explained.

If a run does span an upgrade, **discard its results**. Tests before and after
ran against different code, so neither half is trustworthy on its own.

## Pair the hash with an interface check

A hash tells you the code changed; it does not tell you whether the change
matters to you. Where your CLI supports it, also record the deployed interface:

```bash
stellar contract info interface \
--contract-id <EPHEMERAL_ACCOUNT_ID> \
--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 |
Loading