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 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 1f5f5a25..686fd29f 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,11 +449,84 @@ 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" + # `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. 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); } 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.