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.