From ea78d8e42e2f43be46d86c463b7694f3397edb83 Mon Sep 17 00:00:00 2001 From: echoripplestudio Date: Sat, 29 Aug 2026 16:37:23 +0100 Subject: [PATCH 1/4] fix(reconciliation): retain every Merkle batch so old proofs stay verifiable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #1027. `submit_merkle_root` wrote each new batch to the single `DataKey::CurrentBatch` key, overwriting the previous one, and `verify_settlement` only ever read that key. A proof generated for a payment reconciled in an earlier cycle became permanently unverifiable the moment the next batch landed, even though it was validly included at the time — leaving auditors and dispute flows with no on-chain way to check a settlement older than one cycle. Every batch is now also stored under `DataKey::Batch(id)`, with `submit_merkle_root` returning the assigned ID and the submission event carrying it so an indexer can ask for the root again later. Batches go to persistent storage rather than instance storage: instance storage is bounded and shares a single TTL, so an unbounded history there would eventually stop the contract working. The `CurrentBatch` pointer stays where it was. `verify_settlement_proof(batch_id, payment_id, proof)` verifies against the root the proof was actually generated for, and returns `true` when the proof is valid — the opposite of `verify_settlement`, whose inverted return the issue flags as a footgun. An unknown batch ID panics rather than returning false: a missing root is not the same as an unsettled payment, and collapsing the two would let a caller read one as the other. `verify_settlement` keeps both its current-batch-only behaviour and its inverted return so existing integrators are unaffected; its doc comment now states both plainly and points at the replacement. Both verifiers share one `compute_root` helper so they cannot drift apart. Also adds `get_batch`, `get_latest_stored_batch` and `batch_count` for addressing the history, and tests covering ID assignment, retention across cycles, an old proof verifying after a newer batch lands, both polarities of the new verifier, the unknown-batch panic, and `get_current_batch` still tracking the newest submission. --- .../contracts/reconciliation/src/lib.rs | 144 +++++++++++++++-- .../contracts/reconciliation/src/test.rs | 153 ++++++++++++++++++ 2 files changed, 287 insertions(+), 10 deletions(-) diff --git a/dabdub_contracts/contracts/reconciliation/src/lib.rs b/dabdub_contracts/contracts/reconciliation/src/lib.rs index 46acba27..c1cf2905 100644 --- a/dabdub_contracts/contracts/reconciliation/src/lib.rs +++ b/dabdub_contracts/contracts/reconciliation/src/lib.rs @@ -19,11 +19,30 @@ pub struct ReconciliationBatch { pub submitted_ledger: u32, } +/// A batch together with the ID it is stored under. Returned by the +/// history-aware accessors so a caller can record which root a proof was +/// verified against. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct StoredBatch { + pub batch_id: u32, + pub batch: ReconciliationBatch, +} + #[contracttype] #[derive(Clone)] pub enum DataKey { Admin, + /// The most recently submitted batch. Retained so `get_current_batch` and + /// the original `verify_settlement` keep working unchanged. CurrentBatch, + /// A batch by its ID. Every submitted batch is kept under its own key, so + /// a proof generated for an earlier cycle stays verifiable after later + /// batches land. + Batch(u32), + /// ID that will be assigned to the next submitted batch. Absent until the + /// first submission, which takes ID 0. + NextBatchId, } #[contracttype] @@ -32,6 +51,9 @@ pub struct ReconciliationSubmittedEvent { pub merkle_root: BytesN<32>, pub submitted_at: u64, pub submitted_ledger: u32, + /// ID this batch was stored under. An indexer needs it to ask for the + /// historical root later. + pub batch_id: u32, } #[contract] @@ -43,7 +65,19 @@ impl ReconciliationContract { env.storage().instance().set(&DataKey::Admin, &admin); } - pub fn submit_merkle_root(env: Env, caller: Address, merkle_root: BytesN<32>) { + /// Records a new reconciliation batch and returns the ID it was stored + /// under. + /// + /// Every batch is kept under its own `DataKey::Batch(id)` in addition to + /// the `CurrentBatch` pointer. Overwriting a single slot, as this did + /// before, made a proof unverifiable the moment the next cycle landed even + /// though it was validly included at the time. + /// + /// Batches are written to persistent storage: instance storage is bounded + /// and shares one TTL, so a growing history stored there would eventually + /// stop the contract from functioning. The `CurrentBatch` pointer stays in + /// instance storage, where it already was. + pub fn submit_merkle_root(env: Env, caller: Address, merkle_root: BytesN<32>) -> u32 { caller.require_auth(); Self::require_admin(&env, &caller); @@ -52,7 +86,18 @@ impl ReconciliationContract { submitted_at: env.ledger().timestamp(), submitted_ledger: env.ledger().sequence(), }; + + let batch_id: u32 = env + .storage() + .instance() + .get(&DataKey::NextBatchId) + .unwrap_or(0); + + env.storage().persistent().set(&DataKey::Batch(batch_id), &batch); env.storage().instance().set(&DataKey::CurrentBatch, &batch); + env.storage() + .instance() + .set(&DataKey::NextBatchId, &(batch_id + 1)); env.events().publish( ("RECONCILIATION", "submitted"), @@ -60,11 +105,52 @@ impl ReconciliationContract { merkle_root, submitted_at: batch.submitted_at, submitted_ledger: batch.submitted_ledger, + batch_id, }, ); + + batch_id + } + + /// Verifies `proof` for `payment_id` against the root of batch `batch_id`. + /// + /// Returns `true` when the proof is valid — the opposite of the older + /// [`Self::verify_settlement`], whose inverted return is a documented + /// footgun. Prefer this function. + /// + /// Because the root is addressed by ID, a proof stays verifiable for as + /// long as its batch is retained, rather than only until the next + /// reconciliation cycle. + /// + /// # Panics + /// + /// If no batch is stored under `batch_id`. A missing batch is not the same + /// as an invalid proof, and returning `false` for both would let a caller + /// mistake "this root is gone" for "this payment was not settled". + pub fn verify_settlement_proof( + env: Env, + batch_id: u32, + payment_id: BytesN<32>, + proof: Vec, + ) -> bool { + let batch: ReconciliationBatch = env + .storage() + .persistent() + .get(&DataKey::Batch(batch_id)) + .expect("No reconciliation batch with that ID"); + + Self::compute_root(&env, &payment_id, &proof) == batch.merkle_root } /// Returns `true` when a mismatch is detected, `false` when proof is valid. + /// + /// Verifies against the **latest** batch only, so a proof from an earlier + /// reconciliation cycle reports a mismatch even though it was validly + /// included. Its return value is also inverted relative to its name. + /// + /// Both behaviours are preserved for existing callers. New integrations + /// should use [`Self::verify_settlement_proof`], which takes the batch ID + /// the proof was generated against and returns `true` for a valid proof. pub fn verify_settlement(env: Env, payment_id: BytesN<32>, proof: Vec) -> bool { let batch: ReconciliationBatch = env .storage() @@ -72,21 +158,59 @@ impl ReconciliationContract { .get(&DataKey::CurrentBatch) .expect("No reconciliation batch submitted"); - let mut current = Self::hash_leaf(&env, &payment_id); + Self::compute_root(&env, &payment_id, &proof) != batch.merkle_root + } + + pub fn get_current_batch(env: Env) -> Option { + env.storage().instance().get(&DataKey::CurrentBatch) + } + + /// Returns the batch stored under `batch_id`, or `None` if there is none. + pub fn get_batch(env: Env, batch_id: u32) -> Option { + env.storage().persistent().get(&DataKey::Batch(batch_id)) + } + + /// Returns the latest batch together with its ID. + /// + /// The ID is what a caller needs in order to verify a proof against this + /// root later, once further batches have been submitted. + pub fn get_latest_stored_batch(env: Env) -> Option { + let next_id: u32 = env.storage().instance().get(&DataKey::NextBatchId)?; + if next_id == 0 { + return None; + } + let batch_id = next_id - 1; + env.storage() + .persistent() + .get(&DataKey::Batch(batch_id)) + .map(|batch| StoredBatch { batch_id, batch }) + } + + /// Number of batches submitted so far. The valid IDs are `0..batch_count`. + pub fn batch_count(env: Env) -> u32 { + env.storage() + .instance() + .get(&DataKey::NextBatchId) + .unwrap_or(0) + } + + /// Walks `proof` up from the leaf for `payment_id` and returns the root it + /// computes. Shared by both verifiers so they cannot drift apart. + fn compute_root( + env: &Env, + payment_id: &BytesN<32>, + proof: &Vec, + ) -> BytesN<32> { + let mut current = Self::hash_leaf(env, payment_id); for i in 0..proof.len() { let node = proof.get(i).unwrap(); current = if node.is_left { - Self::hash_pair(&env, &node.sibling, ¤t) + Self::hash_pair(env, &node.sibling, ¤t) } else { - Self::hash_pair(&env, ¤t, &node.sibling) + Self::hash_pair(env, ¤t, &node.sibling) }; } - - current != batch.merkle_root - } - - pub fn get_current_batch(env: Env) -> Option { - env.storage().instance().get(&DataKey::CurrentBatch) + current } fn require_admin(env: &Env, caller: &Address) { diff --git a/dabdub_contracts/contracts/reconciliation/src/test.rs b/dabdub_contracts/contracts/reconciliation/src/test.rs index 7fc4fd8e..e900fe8b 100644 --- a/dabdub_contracts/contracts/reconciliation/src/test.rs +++ b/dabdub_contracts/contracts/reconciliation/src/test.rs @@ -132,4 +132,157 @@ fn test_submit_emits_reconciliation_submitted_event() { assert_eq!(payload.merkle_root, root); assert_eq!(payload.submitted_ledger, 77); assert_eq!(payload.submitted_at, 1_720_000_000); + // The ID is what an indexer needs to ask for this root again later. + assert_eq!(payload.batch_id, 0); +} + +// --------------------------------------------------------------------------- +// Historical batches (#1027) +// --------------------------------------------------------------------------- + +/// Builds a two-leaf tree and returns `(root, proof_for_a)`. +fn two_leaf_tree( + env: &Env, + a: &BytesN<32>, + b: &BytesN<32>, +) -> (BytesN<32>, soroban_sdk::Vec) { + let leaf_a = hash_leaf(env, a); + let leaf_b = hash_leaf(env, b); + let root = hash_pair(env, &leaf_a, &leaf_b); + let proof = vec![ + env, + MerkleProofNode { + sibling: leaf_b, + is_left: false, + }, + ]; + (root, proof) +} + +#[test] +fn test_submit_returns_incrementing_batch_ids() { + let (env, client, admin) = setup_env(); + + assert_eq!(client.batch_count(), 0); + assert_eq!(client.submit_merkle_root(&admin, &make_id(&env, 1)), 0); + assert_eq!(client.submit_merkle_root(&admin, &make_id(&env, 2)), 1); + assert_eq!(client.submit_merkle_root(&admin, &make_id(&env, 3)), 2); + assert_eq!(client.batch_count(), 3); +} + +#[test] +fn test_every_submitted_batch_is_retained() { + let (env, client, admin) = setup_env(); + + let first = make_id(&env, 1); + let second = make_id(&env, 2); + client.submit_merkle_root(&admin, &first); + client.submit_merkle_root(&admin, &second); + + // Before this fix the first root was gone the moment the second landed. + assert_eq!(client.get_batch(&0).unwrap().merkle_root, first); + assert_eq!(client.get_batch(&1).unwrap().merkle_root, second); +} + +#[test] +fn test_old_proof_still_verifies_after_a_newer_batch_lands() { + let (env, client, admin) = setup_env(); + + let payment_a = make_id(&env, 10); + let payment_b = make_id(&env, 20); + let (root, proof) = two_leaf_tree(&env, &payment_a, &payment_b); + + let batch_id = client.submit_merkle_root(&admin, &root); + + // A later reconciliation cycle replaces the current root. + client.submit_merkle_root(&admin, &make_id(&env, 99)); + + // The regression this issue is about: the old proof used to become + // permanently unverifiable here. + assert!(client.verify_settlement_proof(&batch_id, &payment_a, &proof)); + + // ...and the legacy entrypoint still reports a mismatch against the + // newest root, which is exactly why the batch-addressed one exists. + assert!(client.verify_settlement(&payment_a, &proof)); +} + +#[test] +fn test_verify_settlement_proof_returns_true_for_a_valid_proof() { + let (env, client, admin) = setup_env(); + + let payment_a = make_id(&env, 30); + let payment_b = make_id(&env, 40); + let (root, proof) = two_leaf_tree(&env, &payment_a, &payment_b); + let batch_id = client.submit_merkle_root(&admin, &root); + + // Note the polarity: true means verified, unlike `verify_settlement`. + assert!(client.verify_settlement_proof(&batch_id, &payment_a, &proof)); +} + +#[test] +fn test_verify_settlement_proof_returns_false_for_an_invalid_proof() { + let (env, client, admin) = setup_env(); + + let payment_a = make_id(&env, 31); + let payment_b = make_id(&env, 41); + let (root, _) = two_leaf_tree(&env, &payment_a, &payment_b); + let batch_id = client.submit_merkle_root(&admin, &root); + + let bogus = vec![ + &env, + MerkleProofNode { + sibling: hash_leaf(&env, &make_id(&env, 99)), + is_left: false, + }, + ]; + + assert!(!client.verify_settlement_proof(&batch_id, &payment_a, &bogus)); +} + +#[test] +#[should_panic(expected = "No reconciliation batch with that ID")] +fn test_verify_settlement_proof_panics_for_an_unknown_batch() { + let (env, client, admin) = setup_env(); + + let payment_a = make_id(&env, 50); + let payment_b = make_id(&env, 60); + let (root, proof) = two_leaf_tree(&env, &payment_a, &payment_b); + client.submit_merkle_root(&admin, &root); + + // A missing batch is not the same as an invalid proof: returning false + // here would let a caller read "this root is gone" as "not settled". + client.verify_settlement_proof(&7, &payment_a, &proof); +} + +#[test] +fn test_get_latest_stored_batch_reports_the_newest_id() { + let (env, client, admin) = setup_env(); + + assert!(client.get_latest_stored_batch().is_none()); + + client.submit_merkle_root(&admin, &make_id(&env, 1)); + let newest = make_id(&env, 2); + client.submit_merkle_root(&admin, &newest); + + let stored = client.get_latest_stored_batch().unwrap(); + assert_eq!(stored.batch_id, 1); + assert_eq!(stored.batch.merkle_root, newest); +} + +#[test] +fn test_get_batch_returns_none_for_an_unknown_id() { + let (_env, client, _admin) = setup_env(); + assert!(client.get_batch(&0).is_none()); +} + +#[test] +fn test_current_batch_still_tracks_the_latest_submission() { + let (env, client, admin) = setup_env(); + + client.submit_merkle_root(&admin, &make_id(&env, 1)); + let newest = make_id(&env, 2); + client.submit_merkle_root(&admin, &newest); + + // Existing callers of get_current_batch see no behaviour change. + assert_eq!(client.get_current_batch().unwrap().merkle_root, newest); } From 2caf30edb4c3d0dfae704918baaefefd329a1729 Mon Sep 17 00:00:00 2001 From: echoripplestudio Date: Sat, 29 Aug 2026 16:38:56 +0100 Subject: [PATCH 2/4] ci: build this workspace's WASM and enforce the 64 KB budget on PRs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #1028. The final two steps were leftover boilerplate from a different project: `cargo wasm-build` is not a real subcommand — no such alias exists in `dabdub_contracts/.cargo/config.toml` or any Cargo.toml — and neither `contracts/paylink` nor `contracts/cheese_pay` is among this workspace's 14 contracts. Nothing in CI built or validated WASM for any real contract. More consequentially, `ci.yml` never ran `make check-wasm-size`, so the documented 64 KB budget was enforced only by `deploy.yml` on the `push: main` and tag paths. A pull request growing a contract past the Soroban deploy limit was not caught until after it merged. The build now runs `make check-wasm-size`, which builds every workspace contract, runs `wasm-opt -Oz`, and fails on any contract over the limit — the same gate `deploy.yml` applies, moved to where it can block a merge. binaryen is installed via apt, matching deploy.yml. The uploaded artifact now points at the real output directory, `dabdub_contracts/target/wasm32v1-none/release/*.wasm`. Two fixes beyond the stale steps, both needed for the above to run at all: the cargo workspace lives in `dabdub_contracts/` and there is no manifest at the repository root, so `cargo test` and `cargo tarpaulin` were also running from the wrong directory — they now carry `working-directory`, and the Codecov `files:` path is corrected to match. The Codecov `name:` and the `LLVM_PROFILE_FILE` prefix, both still naming the other project, are renamed to dabdub. --- .github/workflows/ci.yml | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1d0b2c47..a5c60038 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,7 @@ on: env: RUSTFLAGS: -D warnings CARGO_INCREMENTAL: 0 - LLVM_PROFILE_FILE: target/coverage/cheese-%p-%m.profraw + LLVM_PROFILE_FILE: target/coverage/dabdub-%p-%m.profraw jobs: ci: @@ -29,10 +29,14 @@ jobs: - name: Install Rust wasm target run: rustup target add wasm32v1-none + # The cargo workspace lives in dabdub_contracts/; there is no manifest at + # the repository root, so every cargo invocation has to run from there. - name: cargo test + working-directory: dabdub_contracts run: cargo test - name: Generate coverage report + working-directory: dabdub_contracts run: | mkdir -p target/coverage cargo tarpaulin \ @@ -47,21 +51,31 @@ jobs: uses: codecov/codecov-action@v4 if: github.event_name == 'push' && github.ref == 'refs/heads/main' with: - files: target/coverage/cobertura.xml + files: dabdub_contracts/target/coverage/cobertura.xml flags: rust - name: cheese-contracts + name: dabdub-contracts fail_ci_if_error: false env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} - - name: wasm build - run: cargo wasm-build + # `make check-wasm-size` depends on `make build-optimised`, which shells + # out to wasm-opt. Installed the same way deploy.yml does it. + - name: Install wasm-opt + run: | + sudo apt-get update -qq + sudo apt-get install -y binaryen + + # Builds every workspace contract to wasm, runs wasm-opt -Oz over the + # output, and fails when any contract exceeds the 64 KB Soroban deploy + # limit. deploy.yml already gates this on `main`; running it here means a + # pull request that grows a contract past the limit is caught before it + # merges rather than after. + - name: Build WASM and check size budget + run: make check-wasm-size - name: Upload WASM artifacts uses: actions/upload-artifact@v4 with: name: wasm-contracts - path: | - contracts/paylink/target/wasm32v1-none/release/paylink.wasm - contracts/cheese_pay/target/wasm32v1-none/release/cheese_pay.wasm + path: dabdub_contracts/target/wasm32v1-none/release/*.wasm retention-days: 30 From ea8efae15da57724d8fab32cbf6e52cf10906c42 Mon Sep 17 00:00:00 2001 From: echoripplestudio Date: Sat, 29 Aug 2026 16:41:40 +0100 Subject: [PATCH 3/4] ci(deploy): deploy all 14 workspace contracts, not 3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #1029. Both deploy jobs built and size-checked WASM for the whole workspace but only called `deploy_contract` for `admin_timelock`, `merchant_registry` and `payment_escrow`. The other 11 — including `settlement_ledger`, `multisig_admin` and `fee_calculator`, no less security-relevant than the three that were automated — needed a manual, undocumented deploy with no record of which contract ID came from which commit or WASM hash. All 14 are now deployed, with outputs and the step-summary table covering every one. Ten of them take `__constructor` arguments, which the workflow never passed — even for `admin_timelock` and `merchant_registry`, both of which take an admin. Arguments now come from per-network Actions variables, forwarded through `deploy_contract` after `--`. The step checks every required variable up front and fails naming the missing one, rather than letting an opaque CLI error surface partway through a partial deploy. `payment_escrow` takes an `Option
` registry; it defaults to the `merchant_registry` deployed in the same run, so the two are wired together in one pass, and `REGISTRY_ADDRESS` overrides that to point at an existing registry. Deploy order puts constructor-free contracts first, then admin-only, then multi-argument, so the registry exists before the escrow needs it. `docs/deployment_configuration.md` lists every required variable, which contract consumes it, and the JSON shapes for the two container-typed arguments (`emergency_signers`, `fee_tiers`). Note for whoever merges: deploy.yml will fail on its next run until these variables are configured in repository settings. That is deliberate — the alternative is deploying ten contracts with unset admin addresses. --- .github/workflows/deploy.yml | 299 +++++++++++++++++++++++++++++-- docs/deployment_configuration.md | 80 +++++++++ 2 files changed, 365 insertions(+), 14 deletions(-) create mode 100644 docs/deployment_configuration.md diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 1f5f5a25..ce813f67 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -76,8 +76,19 @@ jobs: runs-on: ubuntu-latest outputs: admin_timelock_id: ${{ steps.deploy.outputs.admin_timelock_id }} + batch_payments_id: ${{ steps.deploy.outputs.batch_payments_id }} + fee_calculator_id: ${{ steps.deploy.outputs.fee_calculator_id }} + fee_distributor_id: ${{ steps.deploy.outputs.fee_distributor_id }} + liquidity_router_id: ${{ steps.deploy.outputs.liquidity_router_id }} merchant_registry_id: ${{ steps.deploy.outputs.merchant_registry_id }} + multisig_admin_id: ${{ steps.deploy.outputs.multisig_admin_id }} payment_escrow_id: ${{ steps.deploy.outputs.payment_escrow_id }} + payment_request_id: ${{ steps.deploy.outputs.payment_request_id }} + rbac_access_id: ${{ steps.deploy.outputs.rbac_access_id }} + reconciliation_id: ${{ steps.deploy.outputs.reconciliation_id }} + settlement_ledger_id: ${{ steps.deploy.outputs.settlement_ledger_id }} + slippage_protection_id: ${{ steps.deploy.outputs.slippage_protection_id }} + stellar_confirmations_id: ${{ steps.deploy.outputs.stellar_confirmations_id }} steps: - uses: actions/checkout@v4 @@ -104,22 +115,141 @@ jobs: - name: Deploy contracts to testnet id: deploy + env: + DEPLOY_ADMIN: ${{ vars.TESTNET_DEPLOY_ADMIN }} + RBAC_SUPER_ADMIN: ${{ vars.TESTNET_RBAC_SUPER_ADMIN }} + XLM_TOKEN: ${{ vars.TESTNET_XLM_TOKEN }} + USDC_TOKEN: ${{ vars.TESTNET_USDC_TOKEN }} + TREASURY: ${{ vars.TESTNET_TREASURY }} + LP_ADDRESS: ${{ vars.TESTNET_LP_ADDRESS }} + LP_SHARE_BPS: ${{ vars.TESTNET_LP_SHARE_BPS }} + FEE_TIERS: ${{ vars.TESTNET_FEE_TIERS }} + MULTISIG_ADMIN_1: ${{ vars.TESTNET_MULTISIG_ADMIN_1 }} + MULTISIG_ADMIN_2: ${{ vars.TESTNET_MULTISIG_ADMIN_2 }} + MULTISIG_ADMIN_3: ${{ vars.TESTNET_MULTISIG_ADMIN_3 }} + ESCROW_DEFAULT_TTL_LEDGERS: ${{ vars.TESTNET_ESCROW_DEFAULT_TTL_LEDGERS }} + EMERGENCY_SIGNERS: ${{ vars.TESTNET_EMERGENCY_SIGNERS }} + EMERGENCY_TREASURY: ${{ vars.TESTNET_EMERGENCY_TREASURY }} + EMERGENCY_COOLDOWN_LEDGERS: ${{ vars.TESTNET_EMERGENCY_COOLDOWN_LEDGERS }} + CONFIRMATION_COUNT: ${{ vars.TESTNET_CONFIRMATION_COUNT }} + REGISTRY_ADDRESS: ${{ vars.TESTNET_REGISTRY_ADDRESS }} run: | + set -euo pipefail + + # ------------------------------------------------------------------ + # Required configuration. Every contract with a `__constructor` needs + # its arguments supplied here; a missing value is reported by name + # rather than surfacing as an opaque CLI error mid-deploy. + # ------------------------------------------------------------------ + require() { + local var=$1 + if [ -z "${!var:-}" ]; then + echo "::error::$var is not set — configure it in the repository's Actions variables" + missing=1 + fi + } + + missing=0 + require DEPLOY_ADMIN + require RBAC_SUPER_ADMIN + require XLM_TOKEN + require USDC_TOKEN + require TREASURY + require LP_ADDRESS + require LP_SHARE_BPS + require FEE_TIERS + require MULTISIG_ADMIN_1 + require MULTISIG_ADMIN_2 + require MULTISIG_ADMIN_3 + require ESCROW_DEFAULT_TTL_LEDGERS + require EMERGENCY_SIGNERS + require EMERGENCY_TREASURY + require EMERGENCY_COOLDOWN_LEDGERS + require CONFIRMATION_COUNT + [ "$missing" -eq 0 ] || exit 1 + + # ------------------------------------------------------------------ + # Deploy helper. Anything after the wasm path is forwarded to the + # contract's constructor. + # ------------------------------------------------------------------ deploy_contract() { local name=$1 local wasm=$2 + shift 2 local id - id=$(stellar contract deploy \ - --wasm "$wasm" \ - --source deployer \ - --network testnet 2>&1 | tail -1) + if [ "$#" -gt 0 ]; then + id=$(stellar contract deploy \ + --wasm "$wasm" \ + --source deployer \ + --network testnet \ + -- "$@" 2>&1 | tail -1) + else + id=$(stellar contract deploy \ + --wasm "$wasm" \ + --source deployer \ + --network testnet 2>&1 | tail -1) + fi echo "${name}_id=${id}" >> "$GITHUB_OUTPUT" echo "Deployed $name: $id" + # Echoed into the environment so a later contract can point at it. + printf -v "${name}_id" '%s' "$id" } - deploy_contract admin_timelock wasm/admin_timelock.wasm - deploy_contract merchant_registry wasm/merchant_registry.wasm - deploy_contract payment_escrow wasm/payment_escrow.wasm + # --- no constructor ------------------------------------------------ + deploy_contract batch_payments wasm/batch_payments.wasm + deploy_contract liquidity_router wasm/liquidity_router.wasm + deploy_contract payment_request wasm/payment_request.wasm + + # --- admin only ---------------------------------------------------- + deploy_contract admin_timelock wasm/admin_timelock.wasm \ + --admin "$DEPLOY_ADMIN" + deploy_contract merchant_registry wasm/merchant_registry.wasm \ + --admin "$DEPLOY_ADMIN" + deploy_contract reconciliation wasm/reconciliation.wasm \ + --admin "$DEPLOY_ADMIN" + deploy_contract settlement_ledger wasm/settlement_ledger.wasm \ + --admin "$DEPLOY_ADMIN" + deploy_contract slippage_protection wasm/slippage_protection.wasm \ + --admin "$DEPLOY_ADMIN" + deploy_contract rbac_access wasm/rbac_access.wasm \ + --super_admin "$RBAC_SUPER_ADMIN" + + # --- multi-argument constructors ----------------------------------- + deploy_contract stellar_confirmations wasm/stellar_confirmations.wasm \ + --admin "$DEPLOY_ADMIN" \ + --confirmation_count "$CONFIRMATION_COUNT" + + deploy_contract multisig_admin wasm/multisig_admin.wasm \ + --admin1 "$MULTISIG_ADMIN_1" \ + --admin2 "$MULTISIG_ADMIN_2" \ + --admin3 "$MULTISIG_ADMIN_3" + + # FEE_TIERS is a JSON array of FeeTier structs. + deploy_contract fee_calculator wasm/fee_calculator.wasm \ + --admin "$DEPLOY_ADMIN" \ + --tiers "$FEE_TIERS" + + deploy_contract fee_distributor wasm/fee_distributor.wasm \ + --admin "$DEPLOY_ADMIN" \ + --treasury "$TREASURY" \ + --lp_address "$LP_ADDRESS" \ + --lp_share_bps "$LP_SHARE_BPS" \ + --usdc_token "$USDC_TOKEN" + + # payment_escrow takes an Option
registry. Default it to the + # merchant_registry just deployed, so the two are wired together in + # one run instead of needing a follow-up call; REGISTRY_ADDRESS + # overrides that when pointing at an already-deployed registry. + # EMERGENCY_SIGNERS is a JSON array of addresses. + deploy_contract payment_escrow wasm/payment_escrow.wasm \ + --admin "$DEPLOY_ADMIN" \ + --xlm_token "$XLM_TOKEN" \ + --usdc_token "$USDC_TOKEN" \ + --default_ttl_ledgers "$ESCROW_DEFAULT_TTL_LEDGERS" \ + --registry "${REGISTRY_ADDRESS:-$merchant_registry_id}" \ + --emergency_signers "$EMERGENCY_SIGNERS" \ + --emergency_treasury "$EMERGENCY_TREASURY" \ + --emergency_cooldown_ledgers "$EMERGENCY_COOLDOWN_LEDGERS" - name: Summary run: | @@ -127,8 +257,19 @@ jobs: echo "| Contract | ID |" >> "$GITHUB_STEP_SUMMARY" echo "|---|---|" >> "$GITHUB_STEP_SUMMARY" echo "| admin_timelock | ${{ steps.deploy.outputs.admin_timelock_id }} |" >> "$GITHUB_STEP_SUMMARY" + echo "| batch_payments | ${{ steps.deploy.outputs.batch_payments_id }} |" >> "$GITHUB_STEP_SUMMARY" + echo "| fee_calculator | ${{ steps.deploy.outputs.fee_calculator_id }} |" >> "$GITHUB_STEP_SUMMARY" + echo "| fee_distributor | ${{ steps.deploy.outputs.fee_distributor_id }} |" >> "$GITHUB_STEP_SUMMARY" + echo "| liquidity_router | ${{ steps.deploy.outputs.liquidity_router_id }} |" >> "$GITHUB_STEP_SUMMARY" echo "| merchant_registry | ${{ steps.deploy.outputs.merchant_registry_id }} |" >> "$GITHUB_STEP_SUMMARY" + echo "| multisig_admin | ${{ steps.deploy.outputs.multisig_admin_id }} |" >> "$GITHUB_STEP_SUMMARY" echo "| payment_escrow | ${{ steps.deploy.outputs.payment_escrow_id }} |" >> "$GITHUB_STEP_SUMMARY" + echo "| payment_request | ${{ steps.deploy.outputs.payment_request_id }} |" >> "$GITHUB_STEP_SUMMARY" + echo "| rbac_access | ${{ steps.deploy.outputs.rbac_access_id }} |" >> "$GITHUB_STEP_SUMMARY" + echo "| reconciliation | ${{ steps.deploy.outputs.reconciliation_id }} |" >> "$GITHUB_STEP_SUMMARY" + echo "| settlement_ledger | ${{ steps.deploy.outputs.settlement_ledger_id }} |" >> "$GITHUB_STEP_SUMMARY" + echo "| slippage_protection | ${{ steps.deploy.outputs.slippage_protection_id }} |" >> "$GITHUB_STEP_SUMMARY" + echo "| stellar_confirmations | ${{ steps.deploy.outputs.stellar_confirmations_id }} |" >> "$GITHUB_STEP_SUMMARY" # ----------------------------------------------------------------------- # Deploy to mainnet on version tags — requires manual approval @@ -166,22 +307,141 @@ jobs: - name: Deploy contracts to mainnet id: deploy + env: + DEPLOY_ADMIN: ${{ vars.MAINNET_DEPLOY_ADMIN }} + RBAC_SUPER_ADMIN: ${{ vars.MAINNET_RBAC_SUPER_ADMIN }} + XLM_TOKEN: ${{ vars.MAINNET_XLM_TOKEN }} + USDC_TOKEN: ${{ vars.MAINNET_USDC_TOKEN }} + TREASURY: ${{ vars.MAINNET_TREASURY }} + LP_ADDRESS: ${{ vars.MAINNET_LP_ADDRESS }} + LP_SHARE_BPS: ${{ vars.MAINNET_LP_SHARE_BPS }} + FEE_TIERS: ${{ vars.MAINNET_FEE_TIERS }} + MULTISIG_ADMIN_1: ${{ vars.MAINNET_MULTISIG_ADMIN_1 }} + MULTISIG_ADMIN_2: ${{ vars.MAINNET_MULTISIG_ADMIN_2 }} + MULTISIG_ADMIN_3: ${{ vars.MAINNET_MULTISIG_ADMIN_3 }} + ESCROW_DEFAULT_TTL_LEDGERS: ${{ vars.MAINNET_ESCROW_DEFAULT_TTL_LEDGERS }} + EMERGENCY_SIGNERS: ${{ vars.MAINNET_EMERGENCY_SIGNERS }} + EMERGENCY_TREASURY: ${{ vars.MAINNET_EMERGENCY_TREASURY }} + EMERGENCY_COOLDOWN_LEDGERS: ${{ vars.MAINNET_EMERGENCY_COOLDOWN_LEDGERS }} + CONFIRMATION_COUNT: ${{ vars.MAINNET_CONFIRMATION_COUNT }} + REGISTRY_ADDRESS: ${{ vars.MAINNET_REGISTRY_ADDRESS }} run: | + set -euo pipefail + + # ------------------------------------------------------------------ + # Required configuration. Every contract with a `__constructor` needs + # its arguments supplied here; a missing value is reported by name + # rather than surfacing as an opaque CLI error mid-deploy. + # ------------------------------------------------------------------ + require() { + local var=$1 + if [ -z "${!var:-}" ]; then + echo "::error::$var is not set — configure it in the repository's Actions variables" + missing=1 + fi + } + + missing=0 + require DEPLOY_ADMIN + require RBAC_SUPER_ADMIN + require XLM_TOKEN + require USDC_TOKEN + require TREASURY + require LP_ADDRESS + require LP_SHARE_BPS + require FEE_TIERS + require MULTISIG_ADMIN_1 + require MULTISIG_ADMIN_2 + require MULTISIG_ADMIN_3 + require ESCROW_DEFAULT_TTL_LEDGERS + require EMERGENCY_SIGNERS + require EMERGENCY_TREASURY + require EMERGENCY_COOLDOWN_LEDGERS + require CONFIRMATION_COUNT + [ "$missing" -eq 0 ] || exit 1 + + # ------------------------------------------------------------------ + # Deploy helper. Anything after the wasm path is forwarded to the + # contract's constructor. + # ------------------------------------------------------------------ deploy_contract() { local name=$1 local wasm=$2 + shift 2 local id - id=$(stellar contract deploy \ - --wasm "$wasm" \ - --source deployer \ - --network mainnet 2>&1 | tail -1) + if [ "$#" -gt 0 ]; then + id=$(stellar contract deploy \ + --wasm "$wasm" \ + --source deployer \ + --network mainnet \ + -- "$@" 2>&1 | tail -1) + else + id=$(stellar contract deploy \ + --wasm "$wasm" \ + --source deployer \ + --network mainnet 2>&1 | tail -1) + fi echo "${name}_id=${id}" >> "$GITHUB_OUTPUT" echo "Deployed $name: $id" + # Echoed into the environment so a later contract can point at it. + printf -v "${name}_id" '%s' "$id" } - deploy_contract admin_timelock wasm/admin_timelock.wasm - deploy_contract merchant_registry wasm/merchant_registry.wasm - deploy_contract payment_escrow wasm/payment_escrow.wasm + # --- no constructor ------------------------------------------------ + deploy_contract batch_payments wasm/batch_payments.wasm + deploy_contract liquidity_router wasm/liquidity_router.wasm + deploy_contract payment_request wasm/payment_request.wasm + + # --- admin only ---------------------------------------------------- + deploy_contract admin_timelock wasm/admin_timelock.wasm \ + --admin "$DEPLOY_ADMIN" + deploy_contract merchant_registry wasm/merchant_registry.wasm \ + --admin "$DEPLOY_ADMIN" + deploy_contract reconciliation wasm/reconciliation.wasm \ + --admin "$DEPLOY_ADMIN" + deploy_contract settlement_ledger wasm/settlement_ledger.wasm \ + --admin "$DEPLOY_ADMIN" + deploy_contract slippage_protection wasm/slippage_protection.wasm \ + --admin "$DEPLOY_ADMIN" + deploy_contract rbac_access wasm/rbac_access.wasm \ + --super_admin "$RBAC_SUPER_ADMIN" + + # --- multi-argument constructors ----------------------------------- + deploy_contract stellar_confirmations wasm/stellar_confirmations.wasm \ + --admin "$DEPLOY_ADMIN" \ + --confirmation_count "$CONFIRMATION_COUNT" + + deploy_contract multisig_admin wasm/multisig_admin.wasm \ + --admin1 "$MULTISIG_ADMIN_1" \ + --admin2 "$MULTISIG_ADMIN_2" \ + --admin3 "$MULTISIG_ADMIN_3" + + # FEE_TIERS is a JSON array of FeeTier structs. + deploy_contract fee_calculator wasm/fee_calculator.wasm \ + --admin "$DEPLOY_ADMIN" \ + --tiers "$FEE_TIERS" + + deploy_contract fee_distributor wasm/fee_distributor.wasm \ + --admin "$DEPLOY_ADMIN" \ + --treasury "$TREASURY" \ + --lp_address "$LP_ADDRESS" \ + --lp_share_bps "$LP_SHARE_BPS" \ + --usdc_token "$USDC_TOKEN" + + # payment_escrow takes an Option
registry. Default it to the + # merchant_registry just deployed, so the two are wired together in + # one run instead of needing a follow-up call; REGISTRY_ADDRESS + # overrides that when pointing at an already-deployed registry. + # EMERGENCY_SIGNERS is a JSON array of addresses. + deploy_contract payment_escrow wasm/payment_escrow.wasm \ + --admin "$DEPLOY_ADMIN" \ + --xlm_token "$XLM_TOKEN" \ + --usdc_token "$USDC_TOKEN" \ + --default_ttl_ledgers "$ESCROW_DEFAULT_TTL_LEDGERS" \ + --registry "${REGISTRY_ADDRESS:-$merchant_registry_id}" \ + --emergency_signers "$EMERGENCY_SIGNERS" \ + --emergency_treasury "$EMERGENCY_TREASURY" \ + --emergency_cooldown_ledgers "$EMERGENCY_COOLDOWN_LEDGERS" - name: Summary run: | @@ -189,8 +449,19 @@ jobs: echo "| Contract | ID |" >> "$GITHUB_STEP_SUMMARY" echo "|---|---|" >> "$GITHUB_STEP_SUMMARY" echo "| admin_timelock | ${{ steps.deploy.outputs.admin_timelock_id }} |" >> "$GITHUB_STEP_SUMMARY" + echo "| batch_payments | ${{ steps.deploy.outputs.batch_payments_id }} |" >> "$GITHUB_STEP_SUMMARY" + echo "| fee_calculator | ${{ steps.deploy.outputs.fee_calculator_id }} |" >> "$GITHUB_STEP_SUMMARY" + echo "| fee_distributor | ${{ steps.deploy.outputs.fee_distributor_id }} |" >> "$GITHUB_STEP_SUMMARY" + echo "| liquidity_router | ${{ steps.deploy.outputs.liquidity_router_id }} |" >> "$GITHUB_STEP_SUMMARY" echo "| merchant_registry | ${{ steps.deploy.outputs.merchant_registry_id }} |" >> "$GITHUB_STEP_SUMMARY" + echo "| multisig_admin | ${{ steps.deploy.outputs.multisig_admin_id }} |" >> "$GITHUB_STEP_SUMMARY" echo "| payment_escrow | ${{ steps.deploy.outputs.payment_escrow_id }} |" >> "$GITHUB_STEP_SUMMARY" + echo "| payment_request | ${{ steps.deploy.outputs.payment_request_id }} |" >> "$GITHUB_STEP_SUMMARY" + echo "| rbac_access | ${{ steps.deploy.outputs.rbac_access_id }} |" >> "$GITHUB_STEP_SUMMARY" + echo "| reconciliation | ${{ steps.deploy.outputs.reconciliation_id }} |" >> "$GITHUB_STEP_SUMMARY" + echo "| settlement_ledger | ${{ steps.deploy.outputs.settlement_ledger_id }} |" >> "$GITHUB_STEP_SUMMARY" + echo "| slippage_protection | ${{ steps.deploy.outputs.slippage_protection_id }} |" >> "$GITHUB_STEP_SUMMARY" + echo "| stellar_confirmations | ${{ steps.deploy.outputs.stellar_confirmations_id }} |" >> "$GITHUB_STEP_SUMMARY" - name: Rollback procedure reminder run: | diff --git a/docs/deployment_configuration.md b/docs/deployment_configuration.md new file mode 100644 index 00000000..bda5276a --- /dev/null +++ b/docs/deployment_configuration.md @@ -0,0 +1,80 @@ +# Deployment configuration + +`.github/workflows/deploy.yml` deploys all 14 workspace contracts to testnet on +every merge to `main`, and to mainnet on a `v*` tag. Ten of those contracts take +`__constructor` arguments, so the workflow reads them from GitHub Actions +**variables** (Settings → Secrets and variables → Actions → Variables). + +Values are per-network, prefixed `TESTNET_` or `MAINNET_`. The deploy step +checks every required variable up front and fails naming the missing one, rather +than letting an opaque CLI error surface halfway through a partial deploy. + +> Addresses and tuning values are configuration, not credentials, so they are +> variables rather than secrets. The only secrets involved remain +> `TESTNET_DEPLOY_SECRET` and `MAINNET_DEPLOY_SECRET`, the deployer keypairs. + +## Required variables + +| Variable (per-network prefix) | Used by | Example | +| --- | --- | --- | +| `…_DEPLOY_ADMIN` | `admin_timelock`, `merchant_registry`, `reconciliation`, `settlement_ledger`, `slippage_protection`, `stellar_confirmations`, `fee_calculator`, `fee_distributor`, `payment_escrow` | `GA…` | +| `…_RBAC_SUPER_ADMIN` | `rbac_access` (`super_admin`) | `GA…` | +| `…_XLM_TOKEN` | `payment_escrow` | `CA…` | +| `…_USDC_TOKEN` | `payment_escrow`, `fee_distributor` | `CA…` | +| `…_TREASURY` | `fee_distributor` | `GA…` | +| `…_LP_ADDRESS` | `fee_distributor` | `GA…` | +| `…_LP_SHARE_BPS` | `fee_distributor` | `2000` | +| `…_FEE_TIERS` | `fee_calculator` | JSON array, see below | +| `…_MULTISIG_ADMIN_1/2/3` | `multisig_admin` | `GA…` | +| `…_ESCROW_DEFAULT_TTL_LEDGERS` | `payment_escrow` | `518400` | +| `…_EMERGENCY_SIGNERS` | `payment_escrow` | JSON array, see below | +| `…_EMERGENCY_TREASURY` | `payment_escrow` | `GA…` | +| `…_EMERGENCY_COOLDOWN_LEDGERS` | `payment_escrow` | `17280` | +| `…_CONFIRMATION_COUNT` | `stellar_confirmations` | `3` | + +### Optional + +| Variable | Effect | +| --- | --- | +| `…_REGISTRY_ADDRESS` | `payment_escrow`'s `registry` argument. Left unset, it defaults to the `merchant_registry` deployed in the same run, so the two are wired together without a follow-up call. Set it to point at an already-deployed registry instead. | + +## Structured values + +Two constructors take container types, which the Stellar CLI expects as JSON. + +`…_EMERGENCY_SIGNERS` — `Vec
`: + +```json +["GA…", "GB…", "GC…"] +``` + +`…_FEE_TIERS` — `Vec`. Field names must match the struct in +`dabdub_contracts/contracts/fee_calculator/src/lib.rs`: + +```json +[ + { "min_amount": "0", "max_amount": "10000000", "fee_bps": 100 }, + { "min_amount": "10000001", "max_amount": "100000000", "fee_bps": 50 } +] +``` + +## Contracts with no constructor + +`batch_payments`, `liquidity_router` and `payment_request` take no constructor +arguments and need no configuration. + +## Deploy order + +The workflow deploys constructor-free contracts first, then admin-only ones, +then the multi-argument ones. `merchant_registry` is deployed before +`payment_escrow` so the registry address is available to it. + +Contract IDs for every deployed contract are written to the job summary and +exposed as job outputs named `_id`. + +## Related + +- [`escrow_upgrade_procedure.md`](escrow_upgrade_procedure.md) — upgrading + `payment_escrow` in place. +- [`../dabdub_contracts/contracts/payment_escrow/EMERGENCY_RUNBOOK.md`](../dabdub_contracts/contracts/payment_escrow/EMERGENCY_RUNBOOK.md) + — incident response, including rollback. From 2fb2f57d4fb1eacb1d51268c5c05c5c816880e42 Mon Sep 17 00:00:00 2001 From: echoripplestudio Date: Sat, 29 Aug 2026 16:42:48 +0100 Subject: [PATCH 4/4] docs(deploy): correct the mainnet rollback procedure to upgrade in place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #1030. The rollback reminder told operators to run `stellar contract deploy` with the previous WASM. That installs the WASM and creates a brand-new contract with a new ID and empty storage — it does not touch the live contract. An operator following it verbatim during an incident would stand up an orphaned, unpopulated copy while the contract holding every open escrow, the admin address and the registry pointer kept running the bad code, with every downstream service still pointed at it. Not a rollback at all. The reminder now describes what actually works for `payment_escrow`: `stellar contract install` to get the previous WASM hash, then `stellar contract invoke ... -- upgrade --caller --new_wasm_hash ` against the live contract ID, which calls `update_current_contract_wasm` and preserves all storage. It notes that `upgrade` is admin-only and needs multisig signatures where the admin is a multisig, and that `get_version` counts upgrades rather than naming a code version — it increments on a downgrade too, so the on-chain WASM hash is what to verify against. It also states the limitation the old text hid: `payment_escrow` is the only contract here that implements `upgrade`. The other 13 have no `update_current_contract_wasm`, so rolling one back means deploying a new contract and repointing every reference to the old ID — the registry pointer via `set_registry`, the NestJS backend's configured addresses, anything else holding it — with no storage carried over. That is a migration, and the text says so. EMERGENCY_RUNBOOK.md gains the same procedure so the two documents agree, each cross-referencing the other and docs/escrow_upgrade_procedure.md. --- .github/workflows/deploy.yml | 68 ++++++++++++++++++- .../payment_escrow/EMERGENCY_RUNBOOK.md | 59 ++++++++++++++++ 2 files changed, 124 insertions(+), 3 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index ce813f67..686fd29f 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -463,8 +463,70 @@ jobs: echo "| slippage_protection | ${{ steps.deploy.outputs.slippage_protection_id }} |" >> "$GITHUB_STEP_SUMMARY" echo "| stellar_confirmations | ${{ steps.deploy.outputs.stellar_confirmations_id }} |" >> "$GITHUB_STEP_SUMMARY" + # `stellar contract deploy` installs the WASM and creates a *new* contract + # instance with a *new* ID. It does not touch the live contract, so the + # previous wording here described deploying an empty orphan while the + # contract holding every open escrow kept running the bad code. - name: Rollback procedure reminder run: | - echo "To rollback: redeploy the previous release WASM hash using" - echo " stellar contract deploy --wasm .wasm --source deployer --network mainnet" - echo "Previous WASM artifacts are retained for 30 days in GitHub Actions." + cat <<'ROLLBACK' + ================================ ROLLBACK ================================ + + Do NOT use `stellar contract deploy` to roll back. It creates a NEW + contract with a NEW ID and empty storage; the live contract — holding + every open escrow, the admin address and the registry pointer — keeps + running the bad code, and every service still points at it. + + payment_escrow (in-place, preserves contract ID and storage) + --------------------------------------------------------------------- + 1. Install the previous release WASM to get its hash: + + stellar contract install \ + --wasm /payment_escrow.wasm \ + --source deployer \ + --network mainnet + + 2. Invoke the contract's own upgrade entrypoint with that hash: + + stellar contract invoke \ + --id \ + --source deployer \ + --network mainnet \ + -- upgrade \ + --caller \ + --new_wasm_hash + + `upgrade` is admin-only. If the admin is a multisig, the + transaction needs the required signatures. + + 3. Confirm the swap landed: + + stellar contract invoke --id \ + --network mainnet -- get_version + + `get_version` increments on every upgrade, including a downgrade — + it counts upgrades, it does not name the code version. Verify + against the WASM hash on-chain, not this number. + + Every other contract — no in-place path + --------------------------------------------------------------------- + payment_escrow is the only contract in this workspace that implements + `upgrade`. The other 13 have no `update_current_contract_wasm` + entrypoint, so there is no way to swap their code in place. Rolling + one back means deploying the previous WASM as a new contract and then + repointing everything that references the old ID: + + - merchant_registry's registry pointer in payment_escrow + (`set_registry`) + - the NestJS backend's configured contract addresses + - any other contract holding the old ID + + Storage does NOT carry over. Treat this as a migration, not a + rollback, and plan it as one. + + Previous WASM artifacts are retained for 30 days in GitHub Actions. + + See docs/escrow_upgrade_procedure.md and + dabdub_contracts/contracts/payment_escrow/EMERGENCY_RUNBOOK.md. + ========================================================================== + ROLLBACK diff --git a/dabdub_contracts/contracts/payment_escrow/EMERGENCY_RUNBOOK.md b/dabdub_contracts/contracts/payment_escrow/EMERGENCY_RUNBOOK.md index fdeed2e2..04eb12a9 100644 --- a/dabdub_contracts/contracts/payment_escrow/EMERGENCY_RUNBOOK.md +++ b/dabdub_contracts/contracts/payment_escrow/EMERGENCY_RUNBOOK.md @@ -41,3 +41,62 @@ Use `emergency_drain` only during a confirmed critical security incident that ri - A successful drain records the ledger sequence. - Any subsequent drain attempts before `emergency_cooldown_ledgers` elapses must fail. + +## Rolling back a bad release + +Draining is for funds at risk. If the incident is a bad code release rather than +a live exploit, roll the WASM back instead. + +**Never roll back with `stellar contract deploy`.** It installs the WASM and +creates a brand-new contract with a new ID and empty storage. The live contract +— holding every open escrow, the admin address and the registry pointer — is +untouched and still runs the bad code, while every downstream service continues +to point at it. That is not a rollback; it is deploying an unrelated, unpopulated +copy. + +`payment_escrow` implements `upgrade`, which calls +`update_current_contract_wasm` on the existing contract, so its code can be +swapped in place with all storage preserved: + +1. Install the previous release WASM to obtain its hash: + + ```bash + stellar contract install \ + --wasm /payment_escrow.wasm \ + --source \ + --network mainnet + ``` + +2. Invoke `upgrade` on the live contract with that hash: + + ```bash + stellar contract invoke \ + --id \ + --source \ + --network mainnet \ + -- upgrade \ + --caller \ + --new_wasm_hash + ``` + + `upgrade` is admin-only; a multisig admin needs the required signatures. + +3. Confirm with `get_version`. It increments on every upgrade, a downgrade + included — it counts upgrades and does not name the code version, so verify + against the on-chain WASM hash rather than trusting the number. + +The full procedure, including storage-compatibility constraints and migration +logic, is in [`docs/escrow_upgrade_procedure.md`](../../../docs/escrow_upgrade_procedure.md). + +### The other contracts have no in-place path + +`payment_escrow` is the only contract in this workspace with an `upgrade` +entrypoint. The other 13 cannot have their code replaced. Rolling one back means +deploying the previous WASM as a new contract and repointing every reference to +the old ID — `payment_escrow`'s registry pointer via `set_registry`, the NestJS +backend's configured addresses, and any other contract holding the ID. Storage +does not carry over, so this is a migration and needs planning as one. + +The same guidance is printed by the `Rollback procedure reminder` step at the end +of the mainnet job in [`.github/workflows/deploy.yml`](../../../.github/workflows/deploy.yml); +keep the two in step if either changes.