diff --git a/testnet/policies/reset-policy.md b/testnet/policies/reset-policy.md new file mode 100644 index 0000000..5b5828e --- /dev/null +++ b/testnet/policies/reset-policy.md @@ -0,0 +1,400 @@ +# Testnet Reset and Redeploy Policy + +## Status + +**The honest answer, stated up front: there is no fixed cadence, no guaranteed +notice, and no named authority. What exists is a changelog and a detection +procedure.** + +This document records that situation precisely, and then gives integrators +something they can rely on anyway — which is a set of **signals**, not +promises. + +| Question | Answer as of 2026-09-26 | +|---|---| +| Are the contracts deployed? | **No.** All four are undeployed per `testnet/registry/contract-status.md`. Every "current IDs" in the repository is a historical record from a `2026-07-12` deployment, unverified. | +| Is there a testnet reset cadence? | **No cadence is recorded anywhere in this repository.** The SDF resets testnet periodically; the period is not stated. See [Platform-level reset](#1-platform-level-reset-the-sdf-resets-testnet). | +| Is there a notice commitment before a reset? | **No.** See [Advance notice](#advance-notice). | +| Who may authorise a redeploy? | **Undecided.** No document names an owner. See [Undecided](#undecided). | +| Can a redeploy be announced outside this repo? | No. Nothing else is monitored. See [Undecided](#undecided). | + +## Two independent kinds of reset + +**These are different events with different warning characteristics, and +conflating them is the main way an integrator gets surprised.** A CI pipeline +does not care which one happened; both mean *your stored contract IDs may be +gone*. But they have different causes, different cadences, different remedies, +and only one of them is under any person's control. + +| | 1. Platform-level reset | 2. Bridgelet-specific redeploy | +|---|---|---| +| **What** | The Stellar Development Foundation resets testnet itself. Every account and contract on the network goes away. | Someone re-runs `scripts/deploy-testnet.sh`, or an admin performs an in-place contract `upgrade()`. | +| **Who decides** | The SDF. Not this project, not its maintainers, not its issue tracker. | This project. Currently: whoever holds the deployer key, which is not recorded. | +| **Affects other projects on testnet** | Yes, everyone. | No — only Bridgelet's contracts. | +| **Warning you can get** | None in advance. None from this project, because the project is not told first. | Whatever notice the project chooses to give. Currently: a changelog entry, possibly after the fact. | +| **Recovery** | Re-run the deploy script; new IDs; announce them. | Already recovered, from the project's point of view. An integrator's job. | +| **Detectable by** | Ledger going backwards, IDs disappearing — see [`testnet/runbooks/rpc-outage.md`](../runbooks/rpc-outage.md) §6 | Stored IDs failing to resolve; `contract-status.md` disagreeing with reality | +| **Can it happen without a changelog entry?** | **Yes.** Nobody may be watching. | **It has happened** — see the drift example below. | + +`testnet/docs/changelog.md` is specified to record both: its "How to update +this file" section requires an entry when `scripts/deploy-testnet.sh` is run +**and** when an SDF testnet reset takes the deployment down. So the changelog is +the right place to record either. Whether the entry actually appears is the +unreliable part, and it is unreliable in different ways for each — see above. + +## What is destroyed, and what is not + +The practical consequence an integrator needs is concrete. + +### Destroyed by a redeploy + +| Destroyed | Consequence | +|---|---| +| **All four contract IDs.** `stellar contract deploy` mints a new address every run. | Every ID you stored — in code, in `.env`, in a fixture, in a test — becomes garbage. This is the headline item. | +| **Every `EphemeralAccount` child instance**, because each is a separate contract on testnet. | Recorded payments, nonces, expiry ledgers, terminal status: all gone. | +| **The `SweepController` nonce**, which restarts at `0` after `initialize()`. | Any locally-tracked nonce is now meaningless. A nonce that was high is now low, and vice versa. See [`testnet/runbooks/nonce-desync.md`](../runbooks/nonce-desync.md). | +| **The controller's `authorized_destination` and `authorized_signer`**, unless the redeploy is given the same values. | The signer key is not derived from the chain — `deployments/testnet.json` `config.authorizedSigner` is an input to the deploy. A different value means your signatures will not verify, which looks exactly like a signing bug. | +| **The `ReserveContract` admin and base reserve.** | A redeploy with a different `RESERVE_ADMIN_ADDRESS` leaves the new contract initialised but unconfigured. | + +**The one that actually costs people money, in the only currency that matters +here:** if an integrator submitted a **real payment** into a testnet +`EphemeralAccount` — real in the sense that a real `G...` address sent real +testnet XLM and the contract recorded it — **that payment is gone** at a +redeploy, along with the ability to sweep it. The contract is the only place +the record of it lived. There is no recovery path and no counterpart on the +other side. + +So: **do not treat a testnet ephemeral account as storage for anything you +would miss.** If you have put a payment into one and you still need it, the +deployment is the only copy, and the deployment is transient. This is the same +posture `testnet/examples/README.md` takes — snippets are integration guides, +not a production SDK, and every contract ID must be verified before use. + +### Not destroyed + +| Survives | Note | +|---|---| +| The contract source, in `contracts/` | Obviously. | +| The `soroban-sdk` version, via `Cargo.lock` | Recorded in [`testnet/security/dependency-audit-notes.md`](../security/dependency-audit-notes.md). | +| Every document in `testnet/` and `docs/` | Which is the only reason this policy can exist. | +| **The deployer key.** | Per the main `README.md`, the key is what the project holds; a reset does not rotate it. A redeploy, though, gives whoever holds it a fresh set of contracts to deploy — see [`testnet/security/admin-key-hygiene.md`](../security/admin-key-hygiene.md). | +| Testnet itself, its passphrase, and its endpoints | Only the *state* is reset. `Test SDF Network ; September 2015` and `https://soroban-testnet.stellar.org` are stable. | + +The key asymmetry: **the source and the documentation are the durable +artifacts; every contract ID and every scrap of on-chain state is not.** Build +your assumptions on the first category. + +## Advance notice + +**`testnet/docs/changelog.md` is the notification channel.** Its own opening +line makes the claim: *"If you integrate against testnet, this is the only file +you need to watch."* It is specified to carry an entry for every redeploy, +every in-place upgrade, and every SDF reset. + +Its reliability, assessed honestly: + +| Property | Assessment | +|---|---| +| Is it the single channel? | Yes. Nothing else in the repository is a notification mechanism — no watch, no mailing list, no bot, no CI artifact you can subscribe to. | +| Will a Bridgelet redeploy always be recorded? | **It should.** The specified process requires it and there is one person doing it. It has nevertheless gone unrecorded for other events — see the drift example below. | +| Will an SDF reset always be recorded? | **No.** The project may not be told, may not notice, and may not act before you do. Treat a missing entry as uninformative, not as "no reset happened". | +| Is there any lead time? | **No commitment, and none is possible for an SDF-initiated reset.** A project with a `good-first-issue`-scale maintenance budget cannot promise notice of a decision made elsewhere. Saying otherwise would be a promise nobody can keep. | +| Can you watch it automatically? | Technically, yes — it is a file in a public repository. Nobody has built that, and the repo has no `CONTRIBUTING.md` promising anything. | + +**If you need to be told, you have to check.** There is currently no mechanism +by which this project will come to you. That is a real gap, and the honest +response to it is [don't rely on notice](#what-integrators-should-do-instead-of-assuming-permanence) +rather than a promise that notice will improve. + +## Detecting that your world is no longer valid + +This is the part you can actually rely on, because it does not depend on anyone +telling you anything. Run it as a pre-flight, before a test run that matters. + +### Step 1 — Re-verify the IDs, do not trust the copy + +`testnet/registry/verify-contract-live.md` is the procedure. A read-only +invocation is enough to prove an ID resolves: + +```bash +export NETWORK=testnet +export RPC_URL="https://soroban-testnet.stellar.org" +export READER="" + +# A controller that answers at all is live. +stellar contract invoke \ + --simulate-only \ + --id "" \ + --network "$NETWORK" \ + --source "$READER" \ + -- \ + get_nonce +``` + +Then confirm it is the *controller you think it is*, not just a contract at +that address: + +```bash +# A freshly redeployed controller is at nonce 0. A live one usually is not. +stellar contract invoke --simulate-only \ + --id "" --network "$NETWORK" \ + --source "$READER" -- get_nonce + +# get_info is the real initialization probe; get_status returns Active (0) +# for an uninitialized instance, so it proves nothing. +stellar contract invoke --simulate-only \ + --id "" --network "$NETWORK" \ + --source "$READER" -- get_info +``` + +`testnet/registry/verify-contract-live.md` gives the full per-contract +procedure and the expected values for each read-only method. + +### Step 2 — Check how stale the record is + +`testnet/registry/last-verified.json` carries a timestamp per contract, all +currently `2026-09-24T00:00:00Z`. `testnet/registry/contract-status.md` names a +**30-day** staleness threshold for CI. + +Two things to understand about that mechanism, because it is easy to +misread: + +- It is a **staleness mechanism, not evidence of liveness.** A recent + timestamp says someone looked; it does not say the contract is there. All + four timestamps are recent while every contract is recorded as undeployed. +- **Age is a proxy.** A two-week-old record can still be wrong — see below — and + a two-month-old record on a network nobody has reset can still be right. Use + it to decide whether to re-verify, not to decide whether to trust. + +### Step 3 — Check whether the network itself moved + +A platform-level reset has its own signature, and +[`testnet/runbooks/rpc-outage.md`](../runbooks/rpc-outage.md) §6 sets it out: +treat the network as reset if the latest ledger unexpectedly decreases, old +account or contract IDs disappear, or deployment records no longer resolve. + +```bash +curl -fsS "$RPC_URL" -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"getLatestLedger"}' | + jq -r '.result.sequence' +``` + +If that number is lower than the one your tooling remembers, you are in a new +epoch. Do not replay pre-reset transaction history, do not assume prior +contract storage or nonce values exist, and treat redeployment as a separate +manual operation rather than an outage workaround. + +### Worked example: the drift is already in the repository + +The situation this policy is about is not hypothetical. It is the current +state of this repository, and it is worth reading as the worked example. + +**`testnet/registry/contract-status.md` and `deployments/testnet.json` disagree +today.** The registry records all four contracts as undeployed, IDs `TBD`, Last +Verified `Never`. `deployments/testnet.json` records four concrete `C...` IDs +and a WASM hash from `2026-07-12T10:29:25Z`. `deployment-artifacts/contract-ids.txt` +records the same four IDs. `testnet/docs/network-config.md` publishes the same +four IDs. `testnet/docs/changelog.md` has a dated entry with a deployed commit, +`741aec2`, and the same four IDs. + +An integrator who reads `network-config.md` — the file the config +documentation points at for current IDs — gets four contract IDs. An integrator +who reads the canonical status registry gets `TBD`. **One of them is wrong, +and nothing in the repository says which.** `testnet/examples/README.md` says so +directly: *"The canonical status registry currently conflicts with older +deployment artifacts, so a copied ID is not proof of availability."* + +That is exactly the failure mode this document is about, one step removed from +a reset: **an ID was recorded once, in several places, and nobody has since +checked whether it still resolves.** A redeploy or a reset produces the same +symptom. The difference is that a redeploy *will* be written down, and this +drift was not. + +Two more instances of the same class, for anyone auditing their own setup: +`testnet/docs/network-config.md` states that `deployments/testnet.json` "still +contains `null` placeholders" — it does not; it contains the IDs above. And +`testnet/registry/verify-contract-live.md` heads its `EphemeralAccount` and +`SweepController` sections with "**Expected deployed**: Yes (see +`testnet/registry/contract-status.md`)" — pointing at a document that says the +opposite. Both are documentation defects in files that integrators are told to +trust. Neither is a reset, and both would mislead you exactly as badly. + +The generalisable lesson, and the reason steps 1–3 above are a pre-flight +rather than a one-off: **a contract ID in a file is a claim, not a fact. Resolve +it at runtime.** + +## Policy for this project + +The normative part. What triggers a redeploy, who authorises it, and what must +change in the same commit. + +### Triggers + +| Trigger | Rationale | +|---|---| +| A contract change that cannot be made in place, or that changes the constructor arguments | `EphemeralAccount::initialize` gaining `authorized_signer` is exactly this case — the "Unreleased" section of `testnet/docs/changelog.md` records that existing five-argument calls will fail after redeployment. | +| The `soroban-sdk` version changes | Per [`testnet/security/dependency-audit-notes.md`](../security/dependency-audit-notes.md), the SDK bump must move `tools/sweep-signer` with it, and that is worth doing deliberately and once. | +| A `testnet` deployment tag is cut | `testnet-v1.x` per [`testnet/policies/versioning-scheme.md`](versioning-scheme.md). No recorded deployment carries one, which makes this trigger currently theoretical. | +| An SDF reset takes the deployment down | Recovery, not a choice. | +| A security fix that cannot wait for a workflow | No pause/timelock exists; see [`testnet/security/admin-key-hygiene.md`](../security/admin-key-hygiene.md). | + +A merge to `main` is **not** a trigger. `testnet/docs/faq.md` puts it +correctly: in that narrow sense, contract IDs are *more* stable than on +mainnet, because nothing deploys automatically. + +### Authority + +**Undecided, and this document does not invent it.** No file in the repository +names who may run `scripts/deploy-testnet.sh` or approve a redeploy. The +closest available statements are the main `README.md`, which places the key +with the project, and `testnet/docs/contact-and-ownership.md`, which lists a +Testnet Infrastructure Lead and a Contract Security Team. Neither is a +deployment-authorisation policy, and the deploy workflow is +`workflow_dispatch`-only with no environment protection. + +The one rule that *is* derivable from existing documents, and that this +document adopts: **a redeploy is a state-changing operation on shared +infrastructure and must be treated like one.** The immediate-safety-actions +list in [`testnet/runbooks/reentrancy-suspicion.md`](../runbooks/reentrancy-suspicion.md) +says it directly — do not upgrade or redeploy as an investigation step, because +any emergency mutation needs separate authorisation and a recorded rationale. + +### What must change in the same change + +`testnet/docs/changelog.md` states this list in its "How to update this file" +section. A redeploy is not complete until **all** of these are true: + +| # | Artefact | What must be true afterwards | +|---|---|---| +| 1 | `testnet/docs/changelog.md` | A new dated entry at the top: UTC date, the **deployed commit**, new contract IDs or "unchanged", breaking changes, other changes, and "Action required". | +| 2 | `deployments/testnet.json` | Regenerated by the deploy script, with new IDs and a new `deployedAt`. | +| 3 | `deployment-artifacts/contract-ids.txt` | The four `*_CONTRACT_ID` values updated. | +| 4 | `testnet/docs/network-config.md` | The "Deployed contract IDs" table and the WASM hash row updated. | +| 5 | `testnet/registry/contract-status.md` | Status flipped to deployed, IDs and WASM hashes recorded, per-contract blockers cleared. | +| 6 | `testnet/registry/last-verified.json` | ISO timestamps refreshed **after** someone has actually verified on-chain. | +| 7 | `testnet/registry/wasm-hash-reference.md` | WASM hashes and the commit-to-hash mapping populated. | +| 8 | `testnet/security/dependency-audit-notes.md` | Version chain refreshed, if the SDK or CLI changed. | +| 9 | `testnet/registry/upgrade-history.md` | Only for an in-place `upgrade()`, not for a redeploy — a redeploy is a new ID, not an upgrade. | + +### No redeploy is announced only in source + +**A redeploy that is not in `testnet/docs/changelog.md` did not properly +happen**, regardless of what is true on-chain or what the deploy script wrote. + +The script is the enforcement point, which makes this cheap: make +`deployments/testnet.json` write the commit SHA — which it currently does not +— and the changelog entry has something to reference. The absence of that +field is recorded as a gap in +[`testnet/security/dependency-audit-notes.md`](../security/dependency-audit-notes.md). +`scripts/` is out of scope for this work per +[`testnet/tooling/decision-log.md`](../tooling/decision-log.md); naming the +requirement is the contribution available here. + +For an announcement, use the template in +[`testnet/reset/communication-template.md`](../reset/communication-template.md) +rather than writing a new one. Note that its current text publishes updated IDs +to `config/testnet_contracts.json`, **a path that does not exist in this +repository** — fix the template to point at +`deployment-artifacts/contract-ids.txt` and `deployments/testnet.json` before +using it. + +## What integrators should do instead of assuming permanence + +The most valuable section of this document, because it makes the policy +statement above less load-bearing. Every item here is defensive advice that +holds regardless of what this project decides. + +| Do | Because | +|---|---| +| **Resolve contract IDs at runtime, never hard-code them.** Read them from a config file or environment variable that you can update without a code change. | A redeploy changes the ID and nothing else. This is the single highest-value change. `testnet/docs/faq.md` says it: "Keep your IDs in config, not in code." | +| **Re-verify before a test run**, using the steps above, not once at setup. | The drift example shows a repository that disagrees with itself. Your setup ran once. | +| **Make runs idempotent and re-creatable.** Deploy your own `EphemeralAccount` per run rather than reusing a stored one; expect to start from zero. | `testnet/examples/README.md`: "Use a new value for `NEW_ACCOUNT_ID` on each run." A reusable deployment ID and a newly deployed child account are different things. | +| **Never treat testnet as a system of record.** No real user data, no real value, nothing you would have to reconstruct. | The specific point here is that the *record* is the contract, and the contract is transient. | +| **Treat the controller nonce as having no meaningful value across runs.** Re-read `get_nonce()` immediately before signing; serialise sign-and-submit per controller. | The nonce is global to the controller, not per account, and it restarts at `0` on a redeploy. See [`testnet/runbooks/nonce-desync.md`](../runbooks/nonce-desync.md). | +| **Design your CI to fail loudly on a missing contract, and treat that failure as expected, not as a bug.** | A hard-coded ID that stops resolving is the *correct* signal. A test that passes against the wrong contract is worse. | +| **Keep a canary that is cheap to recreate.** A funded testnet identity via Friendbot, regenerated on failure. | Friendbot is not reset-proof for your *identity* either. `testnet/faucet/README.md` is the reference. | +| **Re-read the changelog at the start of an integration, not once at onboarding.** | It is the only notification channel there is, and it is unreliable. | + +The design consequence, stated as a policy position: **if every integrator +resolves IDs at runtime and re-verifies before a run, a reset becomes a +scheduled annoyance rather than an incident.** That is a better outcome than a +guarantee of stability that cannot honestly be given, and it is why this +section is longer than the policy section. + +## Undecided + +Genuinely open. None of this has been decided, and this document does not +decide it by writing it down in prose. + +1. **Is there a reset cadence?** No. `testnet/docs/network-config.md`, + `testnet/docs/support.md`, `testnet/docs/faq.md`, and + `testnet/examples/README.md` all say the SDF resets testnet periodically. + **None states a period.** The SDF does not publish a fixed one as far as + this repository is concerned, and this project has not recorded an observed + interval. Until someone observes and dates two resets, any number here would + be invented. +2. **Who may authorise a redeploy?** Not decided. See + [Authority](#authority). +3. **Is there any notice commitment?** Not decided, and a *lead time* one is + probably not available for an SDF-initiated reset at this project's + maintenance scale. The honest commitment available today is the detection + procedure, which is why that section is normative and this one is not. +4. **Should deployment tags be applied?** `testnet-v1.x` exists in + [`versioning-scheme.md`](versioning-scheme.md); the `2026-07-12` deployment + carries no tag. Whether every redeploy gets one, and whether the tag is + written down anywhere machine-readable, is undecided. +5. **Does the changelog need a machine-readable companion?** An integrator + could diff a JSON file far more reliably than a Markdown table. Not + proposed, not built, not promised. +6. **Should the drift in [the worked example](#worked-example-the-drift-is-already-in-the-repository) + be treated as an incident?** It is currently three documentation defects + and one registry/artefact disagreement, unrecorded anywhere. Whether it + gets an entry, an issue, or just gets fixed, is a maintainer decision. + +**What would count as fixing this document.** A named deployment authority; a +decision on notice, even a decision that notice is "none beyond the +changelog"; a recorded observed reset interval; a corrected +`communication-template.md`; and a resolution of the ID drift — at which point +this document becomes shorter and more confident, because it would be +describing a mechanism rather than documenting an absence. + +--- + +## Related Documentation + +- [`testnet/docs/changelog.md`](../docs/changelog.md) — the notification + channel, and its "How to update this file" process +- [`testnet/docs/network-config.md`](../docs/network-config.md) — endpoints, + passphrase, and the published contract IDs +- [`testnet/reset/communication-template.md`](../reset/communication-template.md) + — the announcement template and post-reset checklist +- [`testnet/registry/contract-status.md`](../registry/contract-status.md) — the + canonical status table, currently in disagreement with the deployment record +- [`testnet/registry/verify-contract-live.md`](../registry/verify-contract-live.md) + — the pre-flight procedure from [Step 1](#step-1--re-verify-the-ids-do-not-trust-the-copy) +- [`testnet/registry/last-verified.json`](../registry/last-verified.json) — the + staleness mechanism +- [`testnet/registry/upgrade-history.md`](../registry/upgrade-history.md) — + in-place upgrades, which are *not* a redeploy +- [`testnet/runbooks/rpc-outage.md`](../runbooks/rpc-outage.md) — §6, detecting + a reset during recovery +- [`testnet/runbooks/nonce-desync.md`](../runbooks/nonce-desync.md) — what a + nonce reset means for a locally-tracked counter +- [`testnet/examples/README.md`](../examples/README.md) — the posture this + document adopts, and the ID-drift caveat +- [`testnet/security/admin-key-hygiene.md`](../security/admin-key-hygiene.md) — + who holds the key a redeploy would use +- [`testnet/security/dependency-audit-notes.md`](../security/dependency-audit-notes.md) + — the version chain a redeploy refreshes +- [`testnet/policies/versioning-scheme.md`](versioning-scheme.md) — deployment + tags +- [`testnet/tooling/decision-log.md`](../tooling/decision-log.md) — why + `scripts/` is untouched by this work + +--- + +## Version History + +| Version | Date | Changes | +|---|---|---| +| 1.0 | 2026-09-26 | Initial testnet reset and redeploy policy; no cadence or notice commitment established | diff --git a/testnet/security/README.md b/testnet/security/README.md index e182563..05aa8ba 100644 --- a/testnet/security/README.md +++ b/testnet/security/README.md @@ -36,6 +36,9 @@ Testnet is usually dismissed as "not real", so the instinct is that it does not | [`unverified-signature-path-warning.md`](unverified-signature-path-warning.md) | `EphemeralAccount::sweep()` does not verify its signature | Before test-calling `sweep()` directly, or when reasoning about whether signature checking works | | [`admin-key-hygiene.md`](admin-key-hygiene.md) | Handling the testnet deployer/admin key | You hold, are about to hold, or are reviewing the testnet admin key | | [`known-testnet-abuse-patterns.md`](known-testnet-abuse-patterns.md) | What griefing the shared deployment looks like, and the contract behaviour each produces | Testnet behaves unexpectedly and you need to decide "broken" vs "someone is messing with shared state" | +| [`disclosure-process.md`](disclosure-process.md) | How to report a security-relevant finding found while testing against testnet | You have found something and do not know where to send it, or what must not be posted publicly | +| [`reentrancy-observation-log.md`](reentrancy-observation-log.md) | Append-only log of investigated reentrancy-adjacent transactions, including false alarms | You have investigated a transaction that looked reentrancy-adjacent, and the result needs recording | +| [`dependency-audit-notes.md`](dependency-audit-notes.md) | The SDK/CLI version pairing behind the deployed artifacts, and how to triage an SDK advisory | An SDK advisory or behaviour change lands and someone must decide whether testnet is in range | ## Standing rules for this directory diff --git a/testnet/security/dependency-audit-notes.md b/testnet/security/dependency-audit-notes.md new file mode 100644 index 0000000..28a6790 --- /dev/null +++ b/testnet/security/dependency-audit-notes.md @@ -0,0 +1,358 @@ +# Dependency Audit Notes: SDK and CLI Version Pairing + +## Purpose + +A record of which versions of the Soroban SDK, `stellar-cli`, and the Rust +toolchain produced the artifacts the testnet deployment is (or was) running — +kept so that when an SDK advisory or a behaviour change is reported, someone can +answer one question quickly: + +> **Is the thing on testnet built from a version that the advisory affects?** + +The premise is that **a deployed contract's behaviour is frozen at whatever +version built it.** Upgrading `soroban-sdk` in `Cargo.toml` changes nothing +about a WASM module already uploaded and already running. There is no runtime +SDK. So "we fixed it in the SDK" and "the deployment is fixed" are different +statements, and only one of them is about the deployment. + +## Status + +| Question | Answer as of 2026-09-26 | +|---|---| +| Are any contracts deployed? | **No.** All four are undeployed per `testnet/registry/contract-status.md`. | +| Is the WASM hash in `deployments/testnet.json` verified? | **No.** `config.ephemeralAccountWasmHash` is unverified, and `testnet/registry/wasm-hash-reference.md` records `TBD` for all four contracts. | +| Is the version chain below established? | **Documented, not established.** Every link is recorded; none has been confirmed against a live deployment, because there is none. | +| Does this document say the current deployment is unaffected by any SDK advisory? | **No, and it cannot.** There is no deployment. See [What this is not](#what-this-document-is-not). | + +The table below is a *record of intent and provenance*, accurate as of the last +review. It is written ahead of the deployment for the same reason the rest of +`testnet/security/` is. + +## The one coupling that matters most + +**`tools/sweep-signer` pins `soroban-sdk = "22.0.0"` for a correctness reason, +not a tidiness reason.** Its `Cargo.toml` says so: + +> Pin to the same soroban-sdk version bridgelet-core builds against, so +> `Address::to_xdr()` serializes identically to what the deployed contract +> expects. Update this if bridgelet-core's soroban-sdk version changes. + +Why this is a correctness issue and not a version nit: + +`SweepController` verifies every sweep signature against +`SHA256(destination ‖ nonce ‖ controller_id)`, where each component is +serialised with `soroban_sdk::Address::to_xdr()` — the SDK's own XDR +serialisation, **not** a hand-rolled encoding of the `G...`/`C...` StrKey. If +the tool and the contract serialise an address one byte differently, the tool +produces a digest over different bytes, and the contract rejects a signature +that is cryptographically perfectly valid. The failure is a bare +`ed25519_verify` trap with no error code and no diagnostics — the exact +symptom documented in +[`testnet/runbooks/failed-sweep-signature.md`](../runbooks/failed-sweep-signature.md) +as `Cause 2: Wrong Contract ID in Message` and `Cause 3: Destination Address +Mismatch`. + +The format is spelled out in +[`docs/SIGNATURE_FORMAT.md`](../../docs/SIGNATURE_FORMAT.md), which warns at +length against hand-rolling the XDR encoding. That warning and this coupling +are the same fact seen from two directions. + +**Therefore: if `soroban-sdk` is ever changed in `contracts/*/Cargo.toml`, +`tools/sweep-signer/Cargo.toml` must be changed in the same commit, and the +change must be checked against the *resolved* version in both lockfiles, not +against the declared requirement string.** See [The resolved version is what +builds](#the-resolved-version-is-what-builds) below — the declared string is +not the version that compiles. + +## The version record + +Declared versus resolved, and where the authoritative value lives. All values +below were read from the working tree on 2026-09-26. + +| Component | Declared | Resolved in lockfile | Authoritative source | Enforced? | +|---|---|---|---|---| +| `soroban-sdk` — `ephemeral_account`, `sweep_controller`, `reserve_contract`, `account_factory`, `shared` | `"22.0.0"` | **22.0.11** | `contracts/*/Cargo.toml`; `Cargo.lock` | Yes, by `Cargo.lock` | +| `soroban-sdk` (dev-dependencies) | `{"version": "22.0.0", "features": ["testutils"]}` | 22.0.11 | `contracts/*/Cargo.toml`; `Cargo.lock` | Yes | +| `soroban-token-sdk` — `sweep_controller` only | `"22.0.0"` | 22.0.11 | `contracts/sweep_controller/Cargo.toml`; `Cargo.lock` | Yes | +| `stellar-xdr` (transitive) | not declared | 22.1.0 | `Cargo.lock` | Yes | +| `soroban-env-host` (transitive) | not declared | 22.1.3 | `Cargo.lock` | Yes | +| `soroban-spec`, `soroban-sdk-macros` (transitive) | not declared | 22.0.11 | `Cargo.lock` | Yes | +| `ed25519-dalek` — workspace | not declared directly | 2.2.0 | `Cargo.lock` | Yes, by `Cargo.lock` | +| `ed25519-dalek` — `tools/sweep-signer` | pinned `=2.1.1` | 2.1.1, with 3.0.0 also present in the graph | `tools/sweep-signer/Cargo.toml`; `tools/sweep-signer/Cargo.lock` | Yes | +| `stellar-strkey` — `tools/sweep-signer` | `"0.0.9"` | 0.0.9 | `tools/sweep-signer/Cargo.lock` | Yes | +| `stellar-cli` | **23.4.1, in prose only** | not recorded anywhere machine-readable | `scripts/build.sh`, `README.md`, `docs/testing.md`, `testnet/config/soroban-cli-identity-setup.md`, `testnet/registry/verify-contract-live.md`, `.github/workflows/*.yml` | **No** — see below | +| Rust toolchain | 1.89.0, in CI only | not recorded | `.github/workflows/deploy-testnet.yml` (`actions-rs/toolchain`, `toolchain: 1.89.0`) | Only in CI, and that workflow does not deploy | +| WASM build target | `wasm32v1-none` | n/a | `scripts/build.sh`; `scripts/deploy-testnet.sh` (`WASM_DIR`) | Yes, by the script | +| Protocol / ledger version | **not recorded** | n/a | nowhere | **No** | +| Deployment tag | `testnet-v1.x` | n/a | `testnet/policies/versioning-scheme.md` | Not applied to any recorded deployment | + +### The resolved version is what builds + +`soroban-sdk = "22.0.0"` is a **caret requirement**, not a pin. In Cargo it +means `>=22.0.0, <23.0.0`, which is satisfied by 22.0.11 — and 22.0.11 is what +both `Cargo.lock` files currently resolve to. `Cargo.toml` tells you the +admissible range; `Cargo.lock` tells you what was actually compiled. + +Two consequences worth internalising: + +1. **An advisory against 22.0.5 through 22.0.10 would not apply to a build + resolved by these lockfiles**, and an advisory against 22.1.x would not + apply at all. The resolved version is the one that matters. +2. **The two lockfiles can drift apart.** `Cargo.lock` and + `tools/sweep-signer/Cargo.lock` are separate files, updated separately, and + both currently resolve `soroban-sdk` to 22.0.11. If one is regenerated and + the other is not, the tool and the contracts stop being built from the same + SDK — and per the coupling above, that is a signature-correctness risk, not + a tidiness problem. + +### Is there a pinned `stellar-cli` version? + +**A version is named in seven places and enforced in none of them.** This is +itself worth recording. + +Where `23.4.1` appears: + +- `scripts/build.sh` — `cargo install --locked stellar-cli --version 23.4.1`, + followed immediately by "(or a newer stellar-cli release — check + `stellar --version`)" +- `README.md` and `docs/testing.md` — same install line, in the prerequisites +- `testnet/config/soroban-cli-identity-setup.md` and + `testnet/registry/verify-contract-live.md` — same version in a prerequisite + list +- `.github/workflows/deploy-testnet.yml` and `.github/workflows/test.yml` — + `cargo install --locked stellar-cli --version 23.4.1` + +Where it is **not** pinned: + +- No `.tool-versions`, no `rust-toolchain.toml`, no CI matrix, and no + checked-in `stellar --version` output. The two CI workflows also install + `stellar-cli` twice by two different mechanisms — an `install.sh` from + `main` and then a `cargo install --version 23.4.1` — with no check that they + agree. +- `testnet/docs/getting-started.md` says **"v22 or later"** for the same + prerequisite. `scripts/build.sh` says 23.4.1 *or newer*. So the repository + does not have one answer; it has three. +- Neither `stellar-cli` version nor Rust version affects the *WASM* that ends + up deployed — `stellar contract build` invokes `cargo` for the contract + crates, and the contract crates pin their own SDK through `Cargo.lock`. The + CLI is a *deployment-time* and *diagnostics-time* tool. That narrows the + blast radius; it does not eliminate it, because a CLI change can alter how a + transaction is built, encoded, or simulated, and a wrong simulation is how + you conclude a transaction will work when it will not. + +**Conclusion to carry into triage: the SDK is pinned by a lockfile; the CLI is +pinned by a habit.** If a CLI behaviour change is the concern, this repository +cannot currently answer the question, and the fix is to capture +`stellar --version` output in a deployment record. + +## The build-to-deployed-artifact chain + +The chain you need in order to answer "is the live WASM in range": + +``` +soroban-sdk requirement soroban-sdk in Cargo.lock build commit deployed commit WASM hash on testnet + "22.0.0" -> 22.0.11 -> ? -> 741aec2 -> 5e667ea0...62c58e +(caret range) (what compiled) (unknown) (changelog) (unverified) +``` + +What each link is sourced from: + +| Link | Source | State | +|---|---|---| +| Declared SDK requirement | `contracts/*/Cargo.toml` | Recorded. Identical across all five workspace members. | +| Resolved SDK version | `Cargo.lock` | Recorded: 22.0.11. | +| Build commit — the commit the WASM was compiled from | — | **Unknown.** No artefact in the repository records it. | +| Deployed commit | `testnet/docs/changelog.md`, `## 2026-07-12` entry: `Deployed commit: 741aec2` | Recorded. | +| Deployment timestamp | `deployments/testnet.json`, `deployedAt: 2026-07-12T10:29:25Z` | Recorded. | +| WASM hash | `deployments/testnet.json`, `config.ephemeralAccountWasmHash`; repeated in `testnet/docs/network-config.md` and `testnet/docs/changelog.md` | Recorded but **unverified**. | +| Deployed commit → SDK resolved version | would require rebuilding commit `741aec2` and reading *its* `Cargo.lock` | **Not done.** The current `Cargo.lock` reflects `main`, not that commit. | + +### The weak link, stated plainly + +The chain is documented end to end and **established at exactly one point**: the +lockfile on `main`, as read today. + +Three things are missing, in increasing order of how much they matter: + +1. **Nothing is deployed.** `testnet/registry/contract-status.md` records all + four contracts as undeployed with IDs `TBD`. There is no live WASM for the + chain to terminate in. +2. **The recorded WASM hash is unverified.** It appears in three places and + none of them is a measurement. + `testnet/registry/wasm-hash-reference.md` records `TBD` for all four + contracts, and + [`testnet/security/README.md`](README.md) says to treat the `2026-07-12` + IDs as unverified until someone confirms them on-chain per + `testnet/registry/verify-contract-live.md`. +3. **The `deployedAt` commit and the WASM hash are not tied together.** The + changelog says commit `741aec2` was deployed; `deployments/testnet.json` + says a WASM hash was installed; nothing records that the second was produced + by building the first. `scripts/deploy-testnet.sh` builds and deploys in one + run and writes `deployments/testnet.json`, but it records **no commit SHA** + — it writes `deployedAt` and the IDs and the WASM hash, and stops. So even + a perfectly executed deploy produces this file without the one field that + would make the chain closable. + +Point 3 is the actionable one, and it is a small fix: the deploy record needs +a commit SHA. Whether that is a change to `scripts/deploy-testnet.sh` is not +this document's call — `scripts/` is explicitly out of scope for this work, and +`testnet/tooling/decision-log.md` records that boundary. Recording the +requirement here is the contribution; the change is a separate, scoped piece of +work. + +## Triage when an SDK advisory lands + +A procedure, not a promise. Run it in order; do not skip to step 4. + +**Step 1 — Establish what the advisory actually covers.** Record the advisory +identifier, the affected version range, and whether it is a security issue, a +behaviour change, or a build/toolchain change. A behaviour change with no CVE +is still worth running; "not a CVE" is not a reason to skip. + +**Step 2 — Read the recorded resolved version.** From the table above: +`soroban-sdk` 22.0.11, `soroban-token-sdk` 22.0.11, `stellar-xdr` 22.1.0. +Use the *resolved* versions, not the `"22.0.0"` requirement strings. + +**Step 3 — Compare.** + +| If the affected range contains the resolved version | Then | +|---|---| +| Yes, and the deployed WASM was built from a commit whose `Cargo.lock` resolves inside the range | The deployment is in range. Triage continues. | +| Yes, but the deployed WASM was built from a commit resolving **outside** the range | The deployment is not affected. The *source* is. Say so explicitly and separately, because they are different statements and only one is about testnet. | +| No overlap at all | No action on the deployment. Update the record if the advisory changes the picture. | + +**Step 4 — Determine whether the deployed WASM was actually built inside the +range.** This is the step that needs evidence, and it is the one people skip. +The two legitimate ways to establish it: + +- Read the `Cargo.lock` **at the deployed commit**, not at `main`. Checkout the + deployed commit, read its `Cargo.lock`, compare. +- Reproduce the build. `testnet/registry/wasm-hash-reference.md` Method 3 + describes rebuilding at a given commit and comparing hashes. Note the caveat + in that document: the same WASM hash from two rebuilds is evidence; a + mismatch may be a non-deterministic build rather than a different commit. + Issue #532 ("Reproducible build verification confirming deployed WASM hash + matches committed source") is the open work on that. + +**Step 5 — Act, per branch.** + +| Branch | Action | +|---|---| +| Deployment confirmed in range, exploitability plausible on testnet | Report through [`disclosure-process.md`](disclosure-process.md). Log the investigation in [`reentrancy-observation-log.md`](reentrancy-observation-log.md) if it touched a sweep path. | +| Deployment confirmed in range, no plausible testnet exploitation | Record the analysis here with the date and the reasoning. A documented "we looked and here is why it does not apply" is worth more than silence. | +| Deployment confirmed out of range | Bump the SDK in a separate commit from any behaviour change, then refresh this document. | +| **Record stale or missing** | **The current state. Continue below.** | + +### The stale-or-missing branch — the one that matters today + +Right now, steps 1–3 are answerable and step 4 is not. The honest output of +this procedure, run against the repository as it stands, is: + +> The resolved SDK version is known. The deployed WASM is not. No Bridgelet +> contract is deployed. Therefore **whether any deployment is in range cannot +> be determined from this repository**, and no statement to the contrary should +> be published from it. + +Three things follow, and they are the useful output: + +1. **Do not publish a reassurance.** "We are not affected" is not derivable + from anything recorded here. +2. **Do not upgrade the SDK as a response to the advisory, in isolation.** An + SDK bump changes `Address::to_xdr()` behaviour potentially, and it must + move `tools/sweep-signer` with it. An advisory-driven upgrade that skips + the coupling check is a signature-correctness regression caused by a + security response. +3. **Record the advisory in this document's version history** with the date, + the affected range, the resolved version at that time, and the outcome + (`unanswerable — no deployment`, if that is the outcome). A dated record of + a question that could not be answered is more useful next time than an + empty one, because it shows the question was asked. + +## Refresh procedure and cadence + +**When this document must be updated:** + +| Trigger | Required change | +|---|---| +| `soroban-sdk` (or `soroban-token-sdk`, `stellar-xdr`) changes in any `Cargo.toml` | The version table, the version history, and — check — `tools/sweep-signer/Cargo.toml` | +| `Cargo.lock` or `tools/sweep-signer/Cargo.lock` is regenerated | The resolved-version column, even if the declared string is unchanged | +| `stellar-cli` or the Rust toolchain version changes | The corresponding row, and whether the pin is now enforced | +| A deployment happens, or `testnet/registry/contract-status.md` changes | The build-to-deployed-artifact chain, and the "weak link" section | +| An SDK advisory lands | The triage record described above | +| A WASM hash is verified on-chain | The hash, its verification date, and the commit it was rebuilt from | + +**Who.** Whoever made the change. There is no review role for this document +specifically; the useful constraint is that whoever updates the SDK updates +`tools/sweep-signer` in the same commit, and the PR description says so. + +**Cadence.** **None is established, and inventing one would be false +precision.** What exists is a *linkage* rather than a schedule: +`testnet/docs/changelog.md` has its own "How to update this file" process, +which requires an entry at the top for every run of `scripts/deploy-testnet.sh`, +every in-place upgrade, and every SDF testnet reset taking the deployment down. +A redeploy is therefore already a moment where this document must be revisited, +and it is the right moment, because the redeploy path is what produces new +contract IDs and a fresh WASM hash. + +Cross-reference +[`testnet/policies/reset-policy.md`](../policies/reset-policy.md) for the reset +side of the same trigger, and +[`testnet/policies/versioning-scheme.md`](../policies/versioning-scheme.md) for +the `testnet-v1.x` deployment tags — noting that no recorded deployment carries +one, so a redeploy is currently the *only* trigger that reliably fires. + +**The one thing a cadence would buy is freshness of the `stellar-cli` pin.** If +a single recurring maintenance item is ever agreed, "check the SDK/CLI versions +and update this file" is the highest-value candidate, because the SDK is pinned +by a lockfile and the CLI is not pinned at all. + +## What this document is not + +Stated explicitly, because a document with a title like this one attracts +assumptions it cannot support. + +| It is not | Why | +|---|---| +| A CVE feed, advisory mirror, or subscription | Nothing here watches anything. Someone has to notice an advisory and run the triage above. | +| A continuous dependency scanner | No `cargo audit`, no Dependabot, no scheduled job. This is a hand-maintained record. | +| **A claim that the current deployment is unaffected by any advisory** | **There is no deployment.** This is the one thing this document must never be cited for. It records versions and a broken chain; it establishes no conclusion about any live artefact. | +| A lockfile | The lockfiles are the authoritative source. This file describes them and points at them. | +| A compatibility matrix | Nothing in the repository tests across SDK versions. The claim "22.0.11 works" is a build result, not a tested matrix. | +| A place to record deployment IDs | Those belong in `deployments/testnet.json` and `testnet/registry/`. This file only points at them. | + +--- + +## Related Documentation + +- [`README.md`](README.md) — index of this directory; the standing rules this + document inherits +- [`disclosure-process.md`](disclosure-process.md) — the route for a finding + that triage produces +- [`reentrancy-observation-log.md`](reentrancy-observation-log.md) — where an + investigation touching a sweep path gets logged +- [`tools/sweep-signer/`](../../tools/sweep-signer) — the tool whose SDK pin is + the correctness coupling above (referenced, not modified) +- [`docs/SIGNATURE_FORMAT.md`](../../docs/SIGNATURE_FORMAT.md) — the signed + message format, and the warning against hand-rolled XDR encoding +- [`testnet/runbooks/failed-sweep-signature.md`](../runbooks/failed-sweep-signature.md) + — the diagnostic for a signature-format divergence +- [`testnet/registry/wasm-hash-reference.md`](../registry/wasm-hash-reference.md) + — WASM hash verification; the basis of step 4 above +- [`testnet/registry/contract-status.md`](../registry/contract-status.md) — the + deployed/undeployed state this document is written ahead of +- [`testnet/registry/upgrade-history.md`](../registry/upgrade-history.md) — + where a redeployed or upgraded WASM hash should be recorded +- [`testnet/docs/changelog.md`](../docs/changelog.md) — the "How to update this + file" process that defines the refresh trigger +- [`testnet/policies/versioning-scheme.md`](../policies/versioning-scheme.md) — + deployment tags +- [`testnet/tooling/decision-log.md`](../tooling/decision-log.md) — why + `scripts/` is out of scope for this work + +--- + +## Version History + +| Version | Date | Changes | +|---|---|---| +| 1.0 | 2026-09-26 | Initial SDK/CLI version pairing record and advisory triage procedure | diff --git a/testnet/security/disclosure-process.md b/testnet/security/disclosure-process.md new file mode 100644 index 0000000..2982e39 --- /dev/null +++ b/testnet/security/disclosure-process.md @@ -0,0 +1,350 @@ +# Testnet Security Disclosure Process + +## Purpose + +How to report a security-relevant finding you hit while testing against the +shared Bridgelet testnet deployment. + +This is a **lightweight, testnet-appropriate process**, and it is deliberately +one. The main `README.md` status banner reads: + +> **Status:** Active Development - MVP. Not audited. + +The mainnet-grade process that banner implies — an embargo, a private security +team, a coordinated disclosure window, a CVE process — **does not exist in this +repository, and this document does not create it.** What exists is a short +route from "I found something" to "a maintainer knows about it", plus a +definition of what is and is not in scope. + +## Status + +| Question | Answer as of 2026-09-26 | +|---|---| +| Is there a live deployment to report against? | **No.** All four contracts are undeployed per `testnet/registry/contract-status.md`. Anything you report today is about the code or the tooling, not about an incident. | +| Is there a named security contact? | `security@bridgelet.org` and `admin@bridgelet.org` are listed in `testnet/docs/contact-and-ownership.md`. Neither is verified as monitored. | +| Is the repository's private vulnerability reporting enabled? | **No.** See [Private reporting](#private-reporting) below — this is a real gap, not an oversight on your part. | +| Is there an SLA? | No. See [Response commitment](#response-commitment). | + +## This is not a mainnet disclosure policy + +Read the difference before assuming you have the right procedure: + +| | This document | A formal mainnet disclosure policy | +|---|---|---| +| Scope | One shared testnet deployment, pre-audit MVP | Mainnet contracts carrying real value | +| Route | A GitHub issue, or an email if a report is harmful in public | A private channel, ideally with an advisory | +| Timing | Whoever reads it next | Coordinated window, embargo agreed up front | +| Credits | Not offered | Normally offered, via an advisory | +| Existence here | **Yes — this file** | **No.** Nothing in this repo establishes one. | + +**What would have to be true before a heavier policy is warranted.** Not a +matter of taste; these are the conditions: + +1. The contracts are deployed somewhere real funds are at risk. +2. There is an audit in scope, with a defined reportable surface. +3. There is someone who can hold a private conversation and publish an + advisory, i.e. a maintainer with release rights. +4. There is a body of users for whom disclosure timing actually matters. + +None of these hold today. Building an elaborate process now would document an +intention the project has not made. When 1–3 become true, this file should be +replaced, not amended, and the replacement should point here for the history. + +## Scope + +**In scope:** anything a reporter can demonstrate against the checked-in +source or against the shared deployment that has a security consequence for +someone other than the reporter. + +**Explicitly out of scope:** + +| Out of scope | Why | +|---|---| +| Security testing against third-party deployments | Not ours to report on. Tell the operator, not us. | +| Denial of service you can only cause with your own testnet account | That is your own account. See `testnet/security/known-testnet-abuse-patterns.md` for what abuse of *others'* state looks like. | +| Anything requiring you to hold someone else's key | Report it, do not do it. If you are asking "how would I use the admin key", the answer is to not. See `testnet/security/admin-key-hygiene.md`. | +| Mainnet, or any non-testnet network | Out of project scope entirely. | +| Feature requests, gas optimisation, SDK ergonomics | Normal issues. No label in particular. | +| "This feels wrong" with nothing reproducible | Still welcome, but label `question` and expect a conversation, not triage. | + +## Why "it's only testnet" is not the test + +[`testnet/security/README.md`](README.md) argues this at length, and the +argument is worth not repeating. Its three reasons, in short: + +1. **The deployment is shared, so it is a real multi-tenant system.** Other + unrelated parties deploy, sweep through, and configure the same contracts. + One party's action degrades everyone's experience. That is a property of the + environment, not of the contracts. +2. **Some compromises are indistinguishable from bugs until you know to look.** + A sweep failing with a bare `ed25519_verify` trap is frequently another + party's nonce contention, not a signing bug. A `batch_initialize` returning + `success: false` for every account may be index-slot exhaustion from an + earlier caller. +3. **"Only testnet" reasoning has already produced a real weakness.** + `EphemeralAccount::sweep()` accepts an `auth_signature` argument and + discards it. Not exploitable for theft, but a live footgun — see + [`unverified-signature-path-warning.md`](unverified-signature-path-warning.md). + +The practical consequence for you as a reporter: a finding that only reproduces +on testnet is still worth reporting **if it degrades the shared deployment for +other integrators, or if it points at a signature-verification or +replay-protection gap.** Those two categories are the ones that would matter on +mainnet tomorrow. + +## How to report + +### Ordinary findings: a public issue + +Title it with the `[testnet]` prefix, per +[`testnet/docs/support.md`](../docs/support.md): + +``` +[testnet] execute_sweep returned the wrong destination on a fresh account +``` + +Apply these labels, all of which already exist in this repository: + +| Label | When | +|---|---| +| `security` | Always. This is what routes it to the security set. | +| `testnet` | Always. The deployment you observed against. | +| `bug` | The behaviour differs from what the code specifies. | +| `severity:critical` / `severity:high` / `severity:medium` / `severity:low` / `severity:info` | Your assessment. See [Severity](#severity). | +| `area:ephemeral-account` / `area:sweep-controller` / `area:account-factory` / `area:reserve-contract` / `area:cross-contract` | Which contract is involved. | +| `type:threat-model` | The finding is about a missing or wrong analysis, not a live behaviour. | +| `good-first-issue` | The finding is documentation-scale, not code-scale. | + +If you cannot set labels yourself, put the same words in the issue body: + +``` +Labels: security, testnet, bug, severity:high, area:sweep-controller +``` + +### Actively harmful right now, versus merely suspicious + +These need different handling, and the difference is about **public +contribution to the harm**, not about how sure you are. + +| Situation | Route | Why | +|---|---|---| +| Tokens moved to a destination the reporter did not intend | [Private reporting](#private-reporting), and say so in a one-line public issue only after a maintainer acknowledges | A public issue that pastes the transaction is a public timeline for whoever did it | +| A griefing vector that degrades the deployment for others right now | [Private reporting](#private-reporting) | Same reason. Also: stopping it may need a maintainer decision, not yours | +| An account is stuck in a terminal state you cannot exit | Public issue is fine | Degraded, not dangerous, and the account is likely yours | +| A signature is rejected and you cannot tell why | Public issue is fine | `testnet/runbooks/failed-sweep-signature.md` probably answers it first | +| You proved a *path* to theft but have not executed it | [Private reporting](#private-reporting) | Do not execute it, even to be sure | +| A working exploit against your own throwaway account | Private, if it needs a detail that helps someone else; public if it does not | Judge by whether the write-up helps or arms | + +**Do not** retry the suspect transaction, re-run a suspected exploit, or +upgrade/redeploy anything as part of investigating it. Those are the +"immediate safety actions" in +[`testnet/runbooks/reentrancy-suspicion.md`](../runbooks/reentrancy-suspicion.md) +and they are not optional. + +### Private reporting + +The intended private route is GitHub's **Report a vulnerability** button on the +repository's Security tab, and +[`testnet/docs/support.md`](../docs/support.md) tells reporters to use it. + +**That button is not currently available.** As of 2026-09-26 the repository's +private vulnerability reporting is disabled, and the repository has published +zero security advisories. Two other documents in this tree give the same +instruction, and they are describing a facility that does not exist yet. Until +somebody enables it, treat this section as describing intent, not behaviour. + +Practical substitutes, in order of preference: + +1. **`security@bridgelet.org`**, from `testnet/docs/contact-and-ownership.md`. + The most likely to reach a human privately. Whether it is *monitored* is + not established; treat a non-reply as "nobody is watching", not as "nobody + cares". +2. **A public issue carrying only the impact, not the mechanism.** Say what + you observed and what it would enable. Omit the working steps. Then note + that a private write-up is available. This is strictly worse than a real + private channel and should be a fallback, not a first choice. +3. **`admin@bridgelet.org`** for anything that needs the key holder rather than + a security reviewer. + +Whatever you do, do not file a *detail-free* public issue and wait. Say which +of the three routes you used. + +## What to include + +A good report reproduces. The bar is deliberately low, and the shape follows +what [`testnet/runbooks/reentrancy-suspicion.md`](../runbooks/reentrancy-suspicion.md) +captures at investigation time and what +[`testnet/runbooks/incident-postmortem-template.md`](../runbooks/incident-postmortem-template.md) +formalises afterwards. + +**Required, or the report cannot be triaged:** + +| Field | Why it is the field and not something else | +|---|---| +| **Transaction hash** | The single most useful item. It identifies the exact call; a maintainer can look it up and see everything you saw. | +| **Ledger sequence and UTC time** | A shared deployment is a timeline. "Now" is not a position on it. | +| **Contract ID called** | The full `C...` strkey. Not a name, not "the account". | +| **The exact function called** | `SweepController::execute_sweep`, not "the sweep". Argument names help. | +| **Observed vs. expected** | Two separate statements. "Observed: `Swept` with `swept_to` set to a destination I did not pass. Expected: rejected, or `swept_to` equal to my argument." | +| **Exact error, verbatim** | `Error(Contract, #N)`, the trap text, the CLI output. Not a paraphrase. | +| **Client and version** | `stellar-cli` version, SDK and version, whether Rust or JS. | + +**Strongly recommended:** + +- The arguments you actually passed, redacted to remove credentials — see + [the rule below](#what-must-never-be-posted). +- The result of the read-only probes the relevant runbook asks for: + `get_status`, `get_info`, `get_nonce`, `can_sweep`. +- Whether anyone else touched the same account or controller, if you know. +- Your **severity** estimate and your **reasoning** for it, even if unsure. +- Whether this is reproducible from a clean run, or only in your environment. + +**The single question maintainers will ask anyway**, so answer it up front: +*did this affect only your own testnet account, or shared state other people +depend on?* + +## What must never be posted publicly + +This is a rule, not a caveat. It applies to issue bodies, comments, logs, +attached files, screenshots, JSON payloads, and anything copied out of +`.env`. + +**Never post:** + +- A Stellar **secret key** — any StrKey beginning with `S` (56 characters, + base32). Including the value of `SIGNER_SECRET_KEY`. +- An **Ed25519 signing seed** — 32 bytes of hex or binary, in any encoding. + Including `SWEEP_SIGNING_KEY_SEED` and `AUTHORIZED_SIGNER_SECRET`, which + `tools/sweep-signer` reads from the environment. +- **Anyone else's signature**, or any unexpired signed payload — including a + sweep `auth_signature` you received, were sent, or intercepted. +- A **`.env` file**, unredacted, or any paste that brings one along. +- Any credential, token, or API key of any kind. + +Testnet key material is not "just testnet". A testnet deployer key is +**authority** over a shared deployment — see +[`admin-key-hygiene.md`](admin-key-hygiene.md) — and a sweep signature you +publish is a signature a third party may be able to submit before you do. +The same no-secrets discipline that governs `testnet/config/` applies here in +full; it is restated here because a security report is precisely the situation +in which people paste a little too much. + +**If you have already posted a secret:** edit or delete the post immediately, +then say so in the same thread so a maintainer knows to treat the value as +burned. Do not quietly remove it. Rotating testnet key material is cheap; +assuming nobody noticed is not. + +## Severity + +The repository already has a five-step label family; use it rather than +inventing a rubric: + +| Label | Use for | +|---|---| +| `severity:critical` | Unauthorised loss or state change on the shared deployment; a working authorisation or signature bypass | +| `severity:high` | A griefing vector that degrades the deployment for other integrators; a replay-protection gap; privilege escalation without the admin key | +| `severity:medium` | Something that costs another integrator real time, or a missing guard with no demonstrated exploitation path | +| `severity:low` | Confusing or misleading behaviour with no security consequence | +| `severity:info` | A hardening observation, a doc correction, an abuse pattern that is reasoned about but not observed | + +Two cautions: + +- **Severity is about the shared deployment, not about your afternoon.** A + one-line annoyance that is only visible to the person who tripped it is + usually `severity:low`. The design-level rubric, where a different question + is asked of a static threat model, lives in + [`docs/security.md`](../../docs/security.md). Do not derive a third rubric + here; if neither fits, use the labels and explain yourself in prose. +- **This is a shared, low-stakes surface, so severity labels will be + recalibrated.** If a maintainer disagrees with your label, that is a + conversation, not a rejection of the finding. + +## Response commitment + +Stated plainly, because a vague promise is worse than a small one: + +| Commitment | Detail | +|---|---| +| Acknowledgement | Best effort. No response time is guaranteed. `testnet/docs/support.md` already says this, and it is not superseded here. | +| First substantive reply | No promise. This is a volunteer project with no paid maintainers, no on-call rotation, and no `CONTRIBUTING.md` that promises anything. | +| Triage into a code fix | No promise. A finding may be documented and never fixed; that is a legitimate outcome and not a judgement on the reporter. | +| Credit | Not offered. Do not file expecting attribution, a CVE, or a bounty. | +| Confidentiality after a private report | **Not promised.** There is no embargo process to honour one. If that matters to you, that is a legitimate reason to not send a full write-up — see [what would make this heavier](#what-would-count-as-making-this-heavier). | + +The last row is the honest cost of a lightweight process, and it is the main +thing a heavier policy would have to buy. + +## Contract bug, or operational observation? + +The most useful thing this document does is tell you which of two quite +different reports you are filing. The main README's "Not audited" status is +the reason the distinction matters: a **genuine contract bug is a claim about +the code that would still be a bug on mainnet**, and it is the kind of finding +this project most wants and must act on. + +| | A contract bug | An operational observation | +|---|---|---| +| The claim is about | The code in `contracts/` | One shared deployment's current behaviour | +| Would it matter on mainnet? | Yes, as-is | Possibly — but the mechanism is environmental | +| Typical example | A signature that is never verified; a missing terminal-state guard; a replay window in the nonce scheme | Someone else's sweep consumed your nonce; a `batch_initialize` batch failing with `error: None`; the deployment responding differently than `network-config.md` says | +| Where it goes | Public issue, `security` + `bug` + `area:*`, severity from the table above. Mention the unverified-signature path and the namespacing claim in `testnet/docs/changelog.md` if they are involved. | Start with the runbooks, not with an issue. `testnet/runbooks/` is the right first stop for all of these. If the runbook does not explain it, file it — still `security` + `testnet`, but say plainly which state belonged to whom. | +| What makes it a bug rather than an observation | It reproduces **from a clean run, with state you created**, and reading the source explains it | It depends on shared state, and reading the source does not explain it | + +**How to tell them apart in practice.** Deploy your own `EphemeralAccount` +from the WASM hash and drive it alone, per +[`testnet/examples/README.md`](../examples/README.md). If the behaviour follows +you into a private instance, it is a bug. If it disappears, it was an +observation about shared state. + +**Do not upgrade the contract to "check".** The `upgrade()` path is +admin-gated, emits no event, and changes the code for every other integrator — +see [`admin-key-hygiene.md`](admin-key-hygiene.md). Reproduce in your own +instance instead. + +Two findings in this tree are already known and are **not** yours to report +again. Both are documented contract bugs that the source confirms: + +- `EphemeralAccount::sweep()` accepts `auth_signature: BytesN<64>` and discards + it, so a direct call succeeds with a zero signature. See + [`unverified-signature-path-warning.md`](unverified-signature-path-warning.md). +- The **"Unreleased"** section of `testnet/docs/changelog.md` claims error + codes are namespaced per contract (EphemeralAccount 1000–1999, and so on). + The source on `main` contains no such namespacing — the enums are 1–15 + (`EphemeralAccount`), 1–11 then 13 (`SweepController`, with a gap at 12), and + 1–6 (`ReserveContract`). A client that trusts the changelog decodes error + codes wrongly. + +If you find either in the wild, report the *consequence* you hit; the +mechanism is already on the record. + +## Related Documentation + +- [`README.md`](README.md) — index of this directory, and the + design-versus-observation distinction +- [`known-testnet-abuse-patterns.md`](known-testnet-abuse-patterns.md) — the + abuse surface a finding is judged against +- [`unverified-signature-path-warning.md`](unverified-signature-path-warning.md) + — a known contract bug, and the "how to check" walkthrough +- [`admin-key-hygiene.md`](admin-key-hygiene.md) — why testnet key material is + authority, not "just testnet" +- [`testnet/runbooks/reentrancy-suspicion.md`](../runbooks/reentrancy-suspicion.md) + — the evidence-first investigation, including the "pause before you publish" + actions +- [`testnet/runbooks/incident-postmortem-template.md`](../runbooks/incident-postmortem-template.md) + — the long-form record, once triage is under way +- [`testnet/docs/support.md`](../docs/support.md) — the general-purpose + reporting route, and the `[testnet]` title prefix +- [`testnet/docs/contact-and-ownership.md`](../docs/contact-and-ownership.md) + — the contact addresses used above +- [`testnet/config/README.md`](../config/README.md) — the no-secrets + discipline this document inherits +- [`docs/security.md`](../../docs/security.md) — design-level threat model; a + different question, a different document + +--- + +## Version History + +| Version | Date | Changes | +|---|---|---| +| 1.0 | 2026-09-26 | Initial testnet security disclosure process | diff --git a/testnet/security/reentrancy-observation-log.md b/testnet/security/reentrancy-observation-log.md new file mode 100644 index 0000000..5e388c5 --- /dev/null +++ b/testnet/security/reentrancy-observation-log.md @@ -0,0 +1,349 @@ +# Reentrancy Observation Log + +## Status + +**No entries. There is nothing to log yet, and that is the correct state of +this document.** + +As of the last review, **no Bridgelet contracts are deployed to Stellar +testnet.** `testnet/registry/contract-status.md` records all four contracts as +undeployed, with contract IDs `TBD` and a Last Verified value of `Never`. There +is no live deployment, therefore no live transaction, therefore no +reentrancy-adjacent behaviour to have observed. + +This document is written **ahead of** that first observation. It defines a +format and a procedure. It is not a record of anything, and nothing in it +should be read as a claim that a transaction was examined. + +`deployments/testnet.json` and `deployment-artifacts/contract-ids.txt` do +contain contract IDs from a 2026-07-12 deployment, and +`testnet/docs/changelog.md` has a matching dated entry. Those are unverified +per `testnet/registry/contract-status.md` and +`testnet/registry/wasm-hash-reference.md`. Until someone confirms them on-chain +per `testnet/registry/verify-contract-live.md`, this log stays empty. + +## Why a log rather than a runbook + +[`testnet/runbooks/reentrancy-suspicion.md`](../runbooks/reentrancy-suspicion.md) +tells you how to investigate *one* transaction. It does not tell you what the +last twelve investigations concluded, which is the thing that makes a +classification interpretable. + +A reentrancy conclusion is a statement about a *rate*: "this is not +reentrancy" only carries information if you also know how many times it +looked like reentrancy and was not. Without a log of negatives, every +investigation starts from zero institutional memory, re-derives the same +reasoning, and reaches a conclusion with no visible base rate. + +So: **low bar for logging, high bar for concluding.** + +## Append-only discipline + +**Entries are never edited and never deleted.** Not to fix a typo, not to +redact, not to remove an embarrassing false alarm. + +A wrong conclusion is corrected by **appending a new entry that supersedes +the old one and references it by entry ID.** Both entries stay. + +This mirrors `testnet/registry/upgrade-history.md`, which is a log rather than +a state table for the same reason: it exists so a behaviour change can be +correlated to a moment. A log that can be rewritten is not evidence — it is a +narrative, and the value of a narrative is that it can be made to say whatever +is convenient later. + +Practical consequences: + +| Rule | Reason | +|---|---| +| Corrections are new entries | The wrong conclusion is data. "We were wrong, twice" is worth more than a clean-looking log. | +| False alarms are entries, not omissions | They set the base rate. See [What counts as an entry](#what-counts-as-an-entry). | +| Entry IDs are permanent and never reused | A superseding reference must always resolve. | +| Nothing is redacted after the fact | Redaction is deletion with extra steps. Redact *before* filing. | +| Timestamps are UTC and recorded at investigation time, not filing time | An investigation that took three days is a three-day-long fact. | + +## Entry schema + +One row per investigated transaction, under +[Observations](#observations). These fields are required; anything optional is +marked so. + +| Field | Required | Must contain | +|---|---|---| +| **Entry ID** | yes | `RE-0001`, monotonically increasing, never reused. Also the `supersedes` target. | +| **Observed at (UTC)** | yes | `YYYY-MM-DDTHH:MM:SSZ`. When the transaction was seen, not when the entry was written. | +| **Ledger** | yes | The integer sequence number the transaction was included in. | +| **Contract ID** | yes | Full `C...` Strkey, verbatim. A name is not an ID. | +| **WASM hash** | yes | The contract code hash, or the literal `unknown`. Do not copy a hash from `deployments/testnet.json` and present it as observed — say `unverified` if that is what you have. | +| **Transaction hash** | yes | The `getTransaction` hash. For an observation with no submitted transaction, say `none (read-only probe)`. | +| **Function called** | yes | The exact entry point, e.g. `SweepController::execute_sweep`. `unknown` is an acceptable answer, not a good one. | +| **Why it looked reentrancy-adjacent** | yes | The specific observation that triggered the investigation, in one or two sentences. Not the conclusion. | +| **Classification** | yes | One value from [the vocabulary](#classification-vocabulary). No free text. | +| **Confidence** | yes | `high`, `medium`, or `low`. | +| **Evidence retained** | yes | Where the raw material lives: RPC `getTransaction` response, event query, log excerpt, or `none`. Say `none` plainly if nothing was kept. | +| **Investigated by** | yes | A GitHub handle, or `unassigned`. An unassigned entry is better than a fictional owner. | +| **Deployed build** | no | The commit SHA the investigator believes was live, or `unknown`. The runbook requires this to be recorded as unknown rather than assumed. | +| **Supersedes** | no | The entry ID this one corrects, or `none`. | +| **Links** | no | The runbook sections followed, the issue, the postmortem. | + +That is thirteen required fields. It is deliberately more than feels +comfortable, because the runbook's own instruction is to record the deployed +identity as unknown rather than assert that current source behaviour was active +on testnet — and a schema that does not ask for that field will not get it. + +## Classification vocabulary + +A small, closed set. Five values, and they map onto the three conclusions +[`testnet/runbooks/reentrancy-suspicion.md`](../runbooks/reentrancy-suspicion.md) +prescribes at its "Classify and record" step, which is the authority here. +This log does not introduce a parallel scheme; it names the two sub-cases the +runbook already describes inside "not reentrancy" so they can be counted +separately. + +| Value | Runbook term | Use when | +|---|---|---| +| `confirmed-reentrancy` | **Supported reentrancy** | One committed transaction demonstrably re-enters the target while the original invocation frame is still active, and causes an additional unauthorized committed effect. Both halves are required: re-entry alone, without the extra effect, is not this. | +| `not-reentrancy` | **Not reentrancy** | The evidence positively identifies a non-reentrancy cause, but it is not one of the two named below. Normal sequencing, a later direct call, a lifecycle issue. | +| `not-reentrancy:duplicate-or-replay` | **Not reentrancy** (sub-case) | Multiple client submissions with one successful effect, or sequential `AlreadySwept` diagnostics with no second committed sweep. Duplicate relayer submission and replay protection. | +| `not-reentrancy:misconfiguration` | **Not reentrancy** (sub-case) | The cause is a wrong destination, a mismatched `authorized_signer`, a wrong contract ID in a signature, or a different deployed build. `testnet/runbooks/failed-sweep-signature.md` is the first stop. | +| `inconclusive` | **Inconclusive** | Invocation, deployment identity, or state evidence is missing. This is the correct classification when a safe reproduction is not possible — the runbook says to use it. | + +`inconclusive` is a real answer, not a failure. It is how a log records "we +could not tell", which is the single most useful thing for the next +investigator. + +### The indicators that need more evidence + +The runbook's "Indicators that require more evidence" table is the direct +input to the `why it looked reentrancy-adjacent` field. Condensed, so a filer +knows which observations are worth an entry at all: + +| Observation | Initial interpretation | +|---|---| +| The target entered recursively in one active invocation tree, with extra committed effects | The only strong reentrancy indicator. `confirmed-reentrancy` is reachable. | +| More transfers than recorded assets | Duplicate transfer, recorded-data issue, loop, or callback. Investigate the invocation tree. | +| `payment` / `multi_pay` after `swept_mul` | A later direct call or a lifecycle issue. Look at the *separate* transaction. | +| Different destination | Authorization or configuration until reentrancy is proven. Almost never `confirmed-reentrancy`. | +| Multiple `reserve` events | `reclaim_reserve()` is repeatable by design. Not sufficient evidence of re-entry. | +| Multiple submissions, one successful hash | Relayer race. Almost never an entry beyond a line in `not-reentrancy:duplicate-or-replay`. | + +Two things the runbook states that constrain every entry here: **event order +is supporting evidence, not the call tree**, and **calling a function twice +sequentially is not a reentrancy reproduction.** + +## What counts as an entry + +**Log the false alarms. All of them.** + +The reason is the one from the top of this document: a positive is only +interpretable against a rate, and the rate comes from the negatives. A log +containing forty `not-reentrancy:duplicate-or-replay` entries and one +`confirmed-reentrancy` tells the next investigator something a log containing +only the positive does not. A log with only positives is indistinguishable +from a log written by someone who only writes up successes, which destroys its +own credibility. + +Concretely, log an entry when: + +- A transaction, event, or error **made you look** — you started a + reentrancy investigation, however briefly. +- A runbook's indicator table fired, even if you dismissed it in a minute. +- Two parties' activity **interacted** in a way you had to reason about. +- You **were wrong** about something, and the reason you were wrong is worth + recording. + +Do not log an entry for: + +- Reading the source. That is [`docs/reentrancy-analysis.md`](../../docs/reentrancy-analysis.md), + and it is design-level reasoning, not an observation. +- A suspected reentrancy you have not investigated. File it as an issue; the + entry is for investigated things. +- Routine `AlreadySwept` from your own double-submit during a walkthrough, + unless it is part of a real race. The bar is low, not zero. + +The asymmetry to hold onto: **anything that caused a pause goes in; +not everything that caused a pause deserves a conclusion.** + +## Observations + +| Entry ID | Observed at (UTC) | Ledger | Contract ID | WASM hash | Transaction hash | Function | Looked reentrancy-adjacent because | Classification | Confidence | Evidence | Investigated by | Build | Supersedes | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| — | — | — | — | — | — | — | — | — | — | — | — | — | — | + +**No entries. As of 2026-09-26 no Bridgelet contract is deployed to Stellar +testnet** (`testnet/registry/contract-status.md`), so there is no live +transaction to have investigated. The table above is the format, not a +result. + +Add the first entry when the deployment is verified live and someone +investigates their first reentrancy-adjacent observation — including, per +[above](#what-counts-as-an-entry), the first false alarm. A first entry that +is a false alarm is a good first entry. + +## Entry template + +Copy this block verbatim into the table, or use it as the checklist. Every +`required` field from [the schema](#entry-schema) must be filled; `unknown` is +an acceptable value, silence is not. + +```markdown +| RE-0000 | {{YYYY-MM-DDTHH:MM:SSZ}} | {{ledger sequence}} | {{full C... Strkey}} | {{code hash or `unknown`}} | {{tx hash or `none (read-only probe)`}} | {{exact entry point}} | {{the observation that triggered the investigation, observation only, no conclusion}} | {{one value from the classification table}} | {{high / medium / low}} | {{where the raw RPC/event/log material is, or `none`}} | {{GitHub handle or `unassigned`}} | {{commit SHA or `unknown`}} | {{entry ID or `none`}} | +``` + +## Worked example + +> ### ⚠️ Example only — not a record +> +> Everything below is **synthetic**. The contract ID, the transaction hash, the +> ledger, the WASM hash, and the destination are all invented placeholders in +> the style of `testnet/examples/README.md`. No such observation has been made; +> none can have been, because no Bridgelet contract is deployed. This block +> exists to show the format, and **must never be copied into the Observations +> table as an entry.** + +| Entry ID | Observed at (UTC) | Ledger | Contract ID | WASM hash | Transaction hash | Function | Looked reentrancy-adjacent because | Classification | Confidence | Evidence | Investigated by | Build | Supersedes | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| RE-0000 *(example)* | `2026-11-04T14:22:07Z` | `5841201` | `CEXAMPLEEXAMPLEEXAMPLEEXAMPLEEXAMPLEEXAMPLEEXAMPLEEXAMPLEEXA` | `unknown` | `` | `SweepController::execute_sweep` | Two `token.transfer` operations for the same asset inside one transaction, while only one payment had been recorded. Invocation tree was not retrieved. | `inconclusive` | low | `none` — transaction was not preserved before the ledger was queried | `example-investigator` | `unknown` | `none` | + +Three things that example is trying to demonstrate: + +1. **`WASM hash: unknown` and `Build: unknown` are normal and correct.** The + runbook requires the deployed identity to be recorded as unknown rather + than assumed. Filling these in from `deployments/testnet.json` would be + fabricating evidence. +2. **`Evidence retained: none` is recorded, not hidden.** An entry that says + what is missing is worth more than one that implies completeness. +3. **The conclusion is `inconclusive`, not `confirmed-reentrancy`.** More + transfers than recorded assets is a real indicator, but without the + invocation tree it does not meet the bar. The `why it looked + reentrancy-adjacent` field records the observation; the classification + records only what the evidence supports. + +## How to file an entry + +**A pull request against `testnet/security/`, appending one row to the +[Observations](#observations) table.** Nothing else changes — the entry is the +whole change. + +1. Fill the row from the [template](#entry-template). +2. Check the classification against + [the vocabulary](#classification-vocabulary) table. It is a closed set. +3. Check the file against the [no-secrets rule](#privacy-and-no-secrets). +4. Open the PR describing the observation. If the observation is **actively + harmful right now** — tokens moved, or the deployment being degraded — do + not open a public PR until it has been reported privately. Follow + [`disclosure-process.md`](disclosure-process.md) first; this log is + downstream of that decision, not an alternative to it. +5. If the entry is a **superseding** one, reference the original entry ID in + both the `Supersedes` field and the PR description, and say so in the row. + Do not touch the original row. +6. If the investigation went past triage, also complete + [`testnet/runbooks/incident-postmortem-template.md`](../runbooks/incident-postmortem-template.md) + and link it. The log entry is one line; the postmortem is the narrative. + +A PR that adds an entry is a low-barrier, low-risk change. Reviewers should +check the classification and the no-secrets rule, not re-litigate the +investigation. + +## Privacy and no-secrets + +**Entries are public information, and only public information.** A log that +cannot be pasted into a public PR without a redaction pass does not work. + +Permitted, and sufficient for any entry: + +- Contract IDs (`C...`), account addresses (`G...`), asset contract IDs +- Transaction hashes +- Ledger sequence numbers +- Decoded event data and its topic symbols +- The public `authorized_signer` value from `deployments/testnet.json` + (`config.authorizedSigner`) — it is a public key +- Exact error values, including `Error(Contract, #N)` + +Forbidden, without exception: + +- Secret keys, any `S...` StrKey +- Ed25519 signing seeds, in hex or binary +- **Any signature**, including your own. An `auth_signature` is a live + authorization artefact; publishing one hands a third party a usable sweep + until the nonce moves. Refer to it as a hash of the bytes if correlation + matters, not the bytes. +- `.env` contents, unredacted CLI argument dumps, or anything that brings + either along +- Anything belonging to another party that is not on-chain public state + +This is the same standing rule that governs `testnet/security/` as a whole and +is set out in [`README.md`](README.md) and +[`admin-key-hygiene.md`](admin-key-hygiene.md). It is not restated here as a +substitute for reading them; it is restated because a reentrancy entry is +exactly the kind of document where a raw `auth_signature` would be pasted in +"for completeness". + +Blameless wording is the other standing rule. Record what the contracts did and +what the plausible cause was. An entry that names a party without evidence is +noise, and on a shared deployment it is also unfair. + +## Retention and the audit log + +**Entries are permanent. Do not prune, summarise away, or compress this +history when it gets long.** + +The reason is that a reentrancy observation log is not only an operational +triage aid. It is potentially **the evidence base a future audit will be built +on**, and audits want negatives: + +- "Reentrancy was investigated N times; here is every case and here is the + evidence for each" is a materially stronger artefact than a summary. +- An audit that arrives after a real incident will ask what was known and when. + A log that was pruned cannot answer that. +- A log whose *rate* of inconclusive outcomes is visible is itself a finding: + it says the evidence-retention discipline is weak, which is a thing an audit + should know. + +Two adjacent documents, deliberately kept distinct: + +| Concern | Document | +|---|---| +| Design-level reasoning: why reentrancy is not a viable vector here, and which tests check that | [`docs/reentrancy-analysis.md`](../../docs/reentrancy-analysis.md) | +| Operational observation: what actually happened on a real deployment, and how it was classified | **this file** | + +That split is the same one [`testnet/security/README.md`](README.md) draws +between `docs/security.md` and this directory, and it is why a disagreement +between the two is not a contradiction. `docs/reentrancy-analysis.md` can be +right about the design and this log can record a false alarm on a live +transaction; both can be true, and that pair of facts is what makes the design +claim credible. + +When an entry is the basis for something larger — a postmortem, a code fix, an +audit finding, a disclosure — **link to it, do not move it.** The entry stays +here, with its own entry ID, and the larger document points at it. + +--- + +## Related Documentation + +- [`testnet/runbooks/reentrancy-suspicion.md`](../runbooks/reentrancy-suspicion.md) + — the investigation procedure and the classification this log records +- [`testnet/runbooks/incident-postmortem-template.md`](../runbooks/incident-postmortem-template.md) + — the long-form record, for when triage is under way +- [`README.md`](README.md) — index of this directory; the + public-information-only and blameless rules +- [`disclosure-process.md`](disclosure-process.md) — route a harmful finding + privately *before* filing an entry for it +- [`known-testnet-abuse-patterns.md`](known-testnet-abuse-patterns.md) — the + abuse surface these observations are judged against +- [`testnet/registry/event-topics.md`](../registry/event-topics.md) — the event + symbols the runbook's queries filter on +- [`testnet/registry/upgrade-history.md`](../registry/upgrade-history.md) — the + append-only model this log follows +- [`testnet/registry/verify-contract-live.md`](../registry/verify-contract-live.md) + — the pre-flight that establishes a contract ID before it can appear here +- [`docs/reentrancy-analysis.md`](../../docs/reentrancy-analysis.md) — + design-level reasoning; a different concern + +--- + +## Version History + +| Version | Date | Changes | +|---|---|---| +| 1.0 | 2026-09-26 | Initial reentrancy observation log format; no entries |