diff --git a/Cargo.lock b/Cargo.lock index c35d45e..0013bd5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -129,7 +129,7 @@ dependencies = [ name = "anstyle-wincon" version = "3.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", @@ -1795,6 +1795,7 @@ dependencies = [ "serde", "serde_json", "sqlx", + "stellar-strkey 0.0.16", "thiserror 1.0.69", "tokio", "tracing", diff --git a/crates/ingest/Cargo.toml b/crates/ingest/Cargo.toml index 612e5c4..859b75b 100644 --- a/crates/ingest/Cargo.toml +++ b/crates/ingest/Cargo.toml @@ -28,6 +28,7 @@ axum.workspace = true octo-webhooks.workspace = true proptest.workspace = true sqlx.workspace = true +stellar-strkey.workspace = true # Pinned: newer 0.6.x requires edition2024 (Rust >= 1.85), but rust-toolchain.toml pins 1.84.1, # so an unpinned "0.6" resolves to a version this project's own toolchain cannot build. wiremock = "=0.6.2" diff --git a/crates/ingest/tests/adversarial_replay_tests.rs b/crates/ingest/tests/adversarial_replay_tests.rs index 4dfdb65..67e92dc 100644 --- a/crates/ingest/tests/adversarial_replay_tests.rs +++ b/crates/ingest/tests/adversarial_replay_tests.rs @@ -69,7 +69,24 @@ fn database_url() -> Option { std::env::var("DATABASE_URL").ok() } -const BASE: &str = "GDRXE2BQUC3AZNPVFSCEZ76NJ3WWL25FYFK6RGZGIEKWE4SOOHSUJUJ6"; +/// A fresh, syntactically-valid `G...` base account, distinct on every call. +/// +/// Every wallet in this harness needs its own real base account rather than sharing one fixed +/// constant: `octo_store`'s multi-chain schema (#214) now enforces `UNIQUE(chain_id, +/// deposit_address)` across *all* wallets on a chain (previously only `UNIQUE(wallet_id, +/// muxed_id)` was enforced), and `encode_muxed(base, id)` is a pure function of the base account — +/// two wallets sharing one base account would derive identical muxed addresses for the same id +/// and collide against that constraint, which is exactly the on-chain reality this schema now +/// models correctly (two real Stellar accounts never share a base key). +/// +/// The bytes don't need to correspond to a real keypair — `stellar_strkey` only checks the +/// checksum/format, which is all `encode_muxed`'s decode step verifies. +fn fresh_base_account() -> String { + let mut bytes = [0u8; 32]; + bytes[..16].copy_from_slice(Uuid::new_v4().as_bytes()); + bytes[16..].copy_from_slice(Uuid::new_v4().as_bytes()); + format!("{}", stellar_strkey::ed25519::PublicKey(bytes)) +} /// Number of randomized property-test cases to run. Each case does several real Postgres /// round-trips, so this is kept modest by default to keep normal CI fast. Override with @@ -84,10 +101,11 @@ fn fuzz_case_count() -> u32 { /// A fresh wallet + `Ingestor` targeting it, isolated per test case so cases never interact. async fn fresh_ingestor(store: &Store) -> (Ingestor, Uuid) { + let base_account = fresh_base_account(); let wallet = store .create_wallet(NewWallet { network: "testnet", - stellar_account_g: &format!("{BASE}-{}", Uuid::new_v4().simple()), + stellar_account_g: &base_account, sealed_ciphertext: b"ct", sealed_nonce: b"nonce", sealed_salt: b"salt", @@ -98,7 +116,7 @@ async fn fresh_ingestor(store: &Store) -> (Ingestor, Uuid) { }) .await .expect("create wallet"); - let ingestor = Ingestor::new(store.clone(), "http://unused", wallet.id, BASE.to_string()); + let ingestor = Ingestor::new(store.clone(), "http://unused", wallet.id, base_account); (ingestor, wallet.id) } @@ -107,12 +125,17 @@ async fn fresh_ingestor(store: &Store) -> (Ingestor, Uuid) { /// verify not just presence but *which* record ended up recorded (catches cross-record /// corruption, not just duplicate/missing counts). async fn build_pool(store: &Store, wallet_id: Uuid, n: usize) -> Vec { + let base_account = store + .get_wallet(wallet_id) + .await + .expect("get wallet") + .stellar_account_g; let mut pool = Vec::with_capacity(n); for i in 0..n { let addr = store .allocate_address( wallet_id, - |id| encode_muxed(BASE, id as u64).map_err(|_| ()), + |id| encode_muxed(&base_account, id as u64).map_err(|_| ()), Some(&format!("cust-{i}")), serde_json::json!({}), ) @@ -126,7 +149,7 @@ async fn build_pool(store: &Store, wallet_id: Uuid, n: usize) -> Vec Option { std::env::var("DATABASE_URL").ok() } -const BASE: &str = "GDRXE2BQUC3AZNPVFSCEZ76NJ3WWL25FYFK6RGZGIEKWE4SOOHSUJUJ6"; +/// A fresh, syntactically-valid `G...` base account, distinct on every call. +/// +/// Every wallet in this file needs its own real base account rather than sharing one fixed +/// constant: `octo_store`'s multi-chain schema (#214) enforces `UNIQUE(chain_id, +/// deposit_address)` across *all* wallets on a chain (previously only `UNIQUE(wallet_id, +/// muxed_id)` was enforced), and `encode_muxed(base, id)` is a pure function of the base account — +/// two wallets sharing one base account would derive identical muxed addresses for the same id +/// and collide against that constraint. Real Stellar accounts never share a base key, so this +/// also makes the fixture more realistic, not just constraint-satisfying. +/// +/// The bytes don't need to correspond to a real keypair — `stellar_strkey` only checks the +/// checksum/format, which is all `encode_muxed`'s decode step verifies. +fn fresh_base_account() -> String { + let mut bytes = [0u8; 32]; + bytes[..16].copy_from_slice(Uuid::new_v4().as_bytes()); + bytes[16..].copy_from_slice(Uuid::new_v4().as_bytes()); + format!("{}", stellar_strkey::ed25519::PublicKey(bytes)) +} -async fn setup() -> Option<(Store, Ingestor, Uuid)> { +async fn setup() -> Option<(Store, Ingestor, Uuid, String)> { let url = database_url()?; let store = Store::connect(&url).await.expect("connect"); store.migrate().await.expect("migrate"); - // A wallet whose base account is BASE, but with a unique stored account string per test run so - // rows don't collide. We use a unique muxed_address per allocation; BASE is what the ingestor - // matches against, so set the wallet's stored account to BASE-with-suffix is not possible (the - // ingestor compares to account_g we pass in). So create the wallet, then drive the Ingestor - // with account_g = the wallet's stored G... value. - let acct = BASE; // ingestor matches rec.to == account_g; allocate uses real encode_muxed(BASE) + let base_account = fresh_base_account(); let wallet = store .create_wallet(NewWallet { network: "testnet", - stellar_account_g: &format!("{acct}-{}", Uuid::new_v4().simple()), + stellar_account_g: &base_account, sealed_ciphertext: b"ct", sealed_nonce: b"nonce", sealed_salt: b"salt", @@ -50,11 +62,16 @@ async fn setup() -> Option<(Store, Ingestor, Uuid)> { .await .expect("wallet"); - let ingestor = Ingestor::new(store.clone(), "http://unused", wallet.id, BASE.to_string()); - Some((store, ingestor, wallet.id)) + let ingestor = Ingestor::new( + store.clone(), + "http://unused", + wallet.id, + base_account.clone(), + ); + Some((store, ingestor, wallet.id, base_account)) } -fn base_record(id: &str) -> PaymentRecord { +fn base_record(id: &str, base_account: &str) -> PaymentRecord { // Deposits dedup on the Horizon operation id, which is globally unique and persists in the // DB. A fixed literal here made every one of these tests a `Duplicate` on the second run // against the same database, so they only passed on a fresh DB. Suffix a per-run uuid to @@ -68,7 +85,7 @@ fn base_record(id: &str) -> PaymentRecord { transaction_hash: Some(format!("hash-{id}")), transaction_successful: true, from: Some("Gsender".into()), - to: Some(BASE.into()), + to: Some(base_account.into()), to_muxed: None, to_muxed_id: None, asset_type: Some("native".into()), @@ -82,7 +99,7 @@ fn base_record(id: &str) -> PaymentRecord { #[tokio::test] async fn deposit_to_muxed_address_is_attributed() { - let Some((store, ingestor, wallet_id)) = setup().await else { + let Some((store, ingestor, wallet_id, base_account)) = setup().await else { eprintln!("SKIPPED: set DATABASE_URL"); return; }; @@ -91,7 +108,7 @@ async fn deposit_to_muxed_address_is_attributed() { let addr = store .allocate_address( wallet_id, - |id| encode_muxed(BASE, id as u64).map_err(|_| ()), + |id| encode_muxed(&base_account, id as u64).map_err(|_| ()), Some("cust-1"), serde_json::json!({}), ) @@ -99,7 +116,7 @@ async fn deposit_to_muxed_address_is_attributed() { .unwrap(); // A payment sent to that customer's muxed address. - let mut rec = base_record("op-muxed-1"); + let mut rec = base_record("op-muxed-1", &base_account); rec.to_muxed = Some(addr.muxed_address.clone()); let outcome = ingestor.process(&rec).await.unwrap(); @@ -114,14 +131,14 @@ async fn deposit_to_muxed_address_is_attributed() { #[tokio::test] async fn deposit_with_memo_id_is_attributed() { - let Some((store, ingestor, wallet_id)) = setup().await else { + let Some((store, ingestor, wallet_id, base_account)) = setup().await else { return; }; let addr = store .allocate_address( wallet_id, - |id| encode_muxed(BASE, id as u64).map_err(|_| ()), + |id| encode_muxed(&base_account, id as u64).map_err(|_| ()), Some("cust-memo"), serde_json::json!({}), ) @@ -129,7 +146,7 @@ async fn deposit_with_memo_id_is_attributed() { .unwrap(); // Sent to the base account with a numeric memo equal to the muxed id. - let mut rec = base_record("op-memo-1"); + let mut rec = base_record("op-memo-1", &base_account); rec.transaction = Some(TransactionRecord { memo_type: Some("id".into()), memo: Some(addr.muxed_id.to_string()), @@ -145,12 +162,12 @@ async fn deposit_with_memo_id_is_attributed() { #[tokio::test] async fn unattributed_deposit_is_quarantined() { - let Some((store, ingestor, wallet_id)) = setup().await else { + let Some((store, ingestor, wallet_id, base_account)) = setup().await else { return; }; // Plain payment to the base account, no muxed, no memo → recorded but not attributed. - let rec = base_record("op-plain-1"); + let rec = base_record("op-plain-1", &base_account); let outcome = ingestor.process(&rec).await.unwrap(); assert_eq!(outcome, Processed::Recorded { attributed: false }); @@ -161,11 +178,11 @@ async fn unattributed_deposit_is_quarantined() { #[tokio::test] async fn duplicate_operation_is_idempotent() { - let Some((store, ingestor, wallet_id)) = setup().await else { + let Some((store, ingestor, wallet_id, base_account)) = setup().await else { return; }; - let rec = base_record("op-dup-1"); + let rec = base_record("op-dup-1", &base_account); assert_eq!( ingestor.process(&rec).await.unwrap(), Processed::Recorded { attributed: false } @@ -185,11 +202,11 @@ async fn duplicate_operation_is_idempotent() { #[tokio::test] async fn failed_tx_is_skipped() { - let Some((store, ingestor, wallet_id)) = setup().await else { + let Some((store, ingestor, wallet_id, base_account)) = setup().await else { return; }; - let mut rec = base_record("op-failed-1"); + let mut rec = base_record("op-failed-1", &base_account); rec.transaction_successful = false; assert_eq!(ingestor.process(&rec).await.unwrap(), Processed::Skipped); assert_eq!( @@ -204,11 +221,11 @@ async fn failed_tx_is_skipped() { #[tokio::test] async fn payment_to_other_account_is_skipped() { - let Some((store, ingestor, wallet_id)) = setup().await else { + let Some((store, ingestor, wallet_id, base_account)) = setup().await else { return; }; - let mut rec = base_record("op-other-1"); + let mut rec = base_record("op-other-1", &base_account); rec.to = Some("GSOMEOTHERACCOUNT".into()); assert_eq!(ingestor.process(&rec).await.unwrap(), Processed::Skipped); assert_eq!( @@ -223,13 +240,13 @@ async fn payment_to_other_account_is_skipped() { #[tokio::test] async fn missing_amount_and_starting_balance_is_skipped() { - let Some((store, ingestor, wallet_id)) = setup().await else { + let Some((store, ingestor, wallet_id, base_account)) = setup().await else { return; }; // Neither `amount` nor `starting_balance` present: amount_str falls back to "", and // amount::to_stroops("") must return None, so process() must skip cleanly rather than panic. - let mut rec = base_record("op-no-amount-1"); + let mut rec = base_record("op-no-amount-1", &base_account); rec.amount = None; rec.starting_balance = None; @@ -247,13 +264,13 @@ async fn missing_amount_and_starting_balance_is_skipped() { #[tokio::test] async fn credit_asset_with_missing_code_falls_back_to_unknown() { - let Some((store, ingestor, wallet_id)) = setup().await else { + let Some((store, ingestor, wallet_id, base_account)) = setup().await else { return; }; // A non-native asset_type with no asset_code must fall back to the literal "unknown" rather // than panicking or leaving the field empty. - let mut rec = base_record("op-credit-no-code-1"); + let mut rec = base_record("op-credit-no-code-1", &base_account); rec.asset_type = Some("credit_alphanum4".into()); rec.asset_code = None; @@ -267,13 +284,13 @@ async fn credit_asset_with_missing_code_falls_back_to_unknown() { #[tokio::test] async fn missing_transaction_field_yields_no_memo_and_no_panic() { - let Some((store, ingestor, wallet_id)) = setup().await else { + let Some((store, ingestor, wallet_id, base_account)) = setup().await else { return; }; // No joined `transaction` at all: memo_id()'s `self.transaction.as_ref()?` must short-circuit // to None without panicking, and the recorded deposit must carry no memo/ledger. - let mut rec = base_record("op-no-tx-1"); + let mut rec = base_record("op-no-tx-1", &base_account); rec.transaction = None; let outcome = ingestor.process(&rec).await.unwrap(); @@ -288,12 +305,13 @@ async fn missing_transaction_field_yields_no_memo_and_no_panic() { async fn make_usdc_payment_link( store: &Store, wallet_id: Uuid, + base_account: &str, amount_usdc_stroops: i64, ) -> (String, Uuid, Uuid) { let addr = store .allocate_address( wallet_id, - |id| encode_muxed(BASE, id as u64).map_err(|_| ()), + |id| encode_muxed(base_account, id as u64).map_err(|_| ()), None, serde_json::json!({}), ) @@ -319,8 +337,8 @@ async fn make_usdc_payment_link( (addr.muxed_address, link.id, intent.id) } -fn usdc_record(id: &str, to_muxed: String, amount: &str) -> PaymentRecord { - let mut rec = base_record(id); +fn usdc_record(id: &str, base_account: &str, to_muxed: String, amount: &str) -> PaymentRecord { + let mut rec = base_record(id, base_account); rec.to_muxed = Some(to_muxed); rec.asset_type = Some("credit_alphanum4".into()); rec.asset_code = Some("USDC".into()); @@ -331,12 +349,13 @@ fn usdc_record(id: &str, to_muxed: String, amount: &str) -> PaymentRecord { #[tokio::test] async fn underpaid_payment_link_deposit_is_recorded_but_left_unconfirmed() { - let Some((store, ingestor, wallet_id)) = setup().await else { + let Some((store, ingestor, wallet_id, base_account)) = setup().await else { return; }; - let (muxed, link_id, intent_id) = make_usdc_payment_link(&store, wallet_id, 100_000_000).await; - let rec = usdc_record("op-underpaid-1", muxed, "5.0000000"); + let (muxed, link_id, intent_id) = + make_usdc_payment_link(&store, wallet_id, &base_account, 100_000_000).await; + let rec = usdc_record("op-underpaid-1", &base_account, muxed, "5.0000000"); let outcome = ingestor.process(&rec).await.unwrap(); assert_eq!(outcome, Processed::Recorded { attributed: true }); @@ -354,12 +373,13 @@ async fn underpaid_payment_link_deposit_is_recorded_but_left_unconfirmed() { #[tokio::test] async fn overpaid_payment_link_deposit_is_recorded_but_left_unconfirmed() { - let Some((store, ingestor, wallet_id)) = setup().await else { + let Some((store, ingestor, wallet_id, base_account)) = setup().await else { return; }; - let (muxed, link_id, intent_id) = make_usdc_payment_link(&store, wallet_id, 100_000_000).await; - let rec = usdc_record("op-overpaid-1", muxed, "15.0000000"); + let (muxed, link_id, intent_id) = + make_usdc_payment_link(&store, wallet_id, &base_account, 100_000_000).await; + let rec = usdc_record("op-overpaid-1", &base_account, muxed, "15.0000000"); let outcome = ingestor.process(&rec).await.unwrap(); assert_eq!(outcome, Processed::Recorded { attributed: true }); @@ -374,12 +394,13 @@ async fn overpaid_payment_link_deposit_is_recorded_but_left_unconfirmed() { #[tokio::test] async fn exact_payment_link_deposit_confirms() { - let Some((store, ingestor, wallet_id)) = setup().await else { + let Some((store, ingestor, wallet_id, base_account)) = setup().await else { return; }; - let (muxed, link_id, intent_id) = make_usdc_payment_link(&store, wallet_id, 100_000_000).await; - let rec = usdc_record("op-exact-1", muxed, "10.0000000"); + let (muxed, link_id, intent_id) = + make_usdc_payment_link(&store, wallet_id, &base_account, 100_000_000).await; + let rec = usdc_record("op-exact-1", &base_account, muxed, "10.0000000"); let outcome = ingestor.process(&rec).await.unwrap(); assert_eq!(outcome, Processed::Recorded { attributed: true }); diff --git a/crates/store/Cargo.toml b/crates/store/Cargo.toml index 8c8f48b..a9a80e4 100644 --- a/crates/store/Cargo.toml +++ b/crates/store/Cargo.toml @@ -15,8 +15,8 @@ serde_json.workspace = true uuid.workspace = true chrono.workspace = true thiserror.workspace = true +tokio.workspace = true [dev-dependencies] -tokio.workspace = true dotenvy = "0.15" sqlx.workspace = true diff --git a/crates/store/migrations/0021_chains_registry.sql b/crates/store/migrations/0021_chains_registry.sql new file mode 100644 index 0000000..7f6ac8b --- /dev/null +++ b/crates/store/migrations/0021_chains_registry.sql @@ -0,0 +1,29 @@ +-- Multi-chain schema, phase 1: the `chains` registry. +-- +-- See docs/architecture.md ("Data model: multi-chain") for the full migration plan and the +-- backward-compatibility strategy (Refs #214). +-- +-- `chain_id` is a CAIP-2-shaped slug ("namespace:reference"), e.g. `eip155:1` for Ethereum +-- mainnet (real CAIP-2) or `stellar:pubnet` / `stellar:testnet` for Stellar (CAIP-2 has no +-- registered Stellar namespace yet, so we mint an internal slug in the same shape — it only has +-- to be stable and unique within this registry). +CREATE TABLE chains ( + chain_id TEXT PRIMARY KEY, + kind TEXT NOT NULL CHECK (kind IN ('stellar', 'evm')), + native_symbol TEXT NOT NULL, + native_decimals SMALLINT NOT NULL, + -- Ledgers/blocks to wait before treating a deposit as final. Stellar has no reorgs (closed + -- finality), so 1 is a formality; EVM chains will set this to something real per chain. + confirmation_depth INTEGER NOT NULL DEFAULT 1, + enabled BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Seed the two chains that already have live data. `wallets.network` continues to be the source +-- of truth for these values until 0028_chain_id_set_not_null.sql lands and every caller has +-- migrated to `chain_id` directly (see the bridge helper in store/src/lib.rs). +INSERT INTO chains (chain_id, kind, native_symbol, native_decimals, confirmation_depth, enabled) +VALUES + ('stellar:pubnet', 'stellar', 'XLM', 7, 1, true), + ('stellar:testnet', 'stellar', 'XLM', 7, 1, true); diff --git a/crates/store/migrations/0022_chain_scoped_columns.sql b/crates/store/migrations/0022_chain_scoped_columns.sql new file mode 100644 index 0000000..17d6723 --- /dev/null +++ b/crates/store/migrations/0022_chain_scoped_columns.sql @@ -0,0 +1,31 @@ +-- Multi-chain schema, phase 2: additive columns only. +-- +-- Every column added here is NULLABLE with no DEFAULT, so each ADD COLUMN is a fast, metadata-only +-- change on Postgres 11+ (no table rewrite, no long lock) even on a production-sized +-- `transactions` table. `chain_id REFERENCES chains(chain_id)` is safe to add inline (not +-- NOT VALID) for the same reason: every existing row gets chain_id = NULL, and a NULL always +-- satisfies a foreign key, so there is nothing to validate against existing data yet. +-- +-- Backward-compatibility strategy (the "renaming a column breaks the running old binary" question +-- from #214): we keep every legacy column (`network`, `stellar_tx_hash`, `muxed_address`, ...) and +-- add generic ones alongside (`chain_id`, `tx_hash`, `deposit_address`, ...), backfilled from the +-- legacy columns. This avoids a two-release rename dance (view / generated column indirection) at +-- the cost of some duplicated data — see docs/architecture.md for the full rationale. + +ALTER TABLE wallets ADD COLUMN chain_id TEXT REFERENCES chains(chain_id); +CREATE INDEX idx_wallets_chain ON wallets(chain_id); + +ALTER TABLE addresses ADD COLUMN chain_id TEXT REFERENCES chains(chain_id); +-- Generic deposit-address column. For Stellar this mirrors `muxed_address`; for a future EVM +-- adapter it is the actual HD-derived `0x...` address. +ALTER TABLE addresses ADD COLUMN deposit_address TEXT; +-- HD derivation index for EVM addresses (e.g. BIP-44 address_index). Stellar keeps using +-- `muxed_id`, which is not a key-derivation index at all (it's an off-chain routing id), so it is +-- deliberately not reused for this. +ALTER TABLE addresses ADD COLUMN derivation_index BIGINT; + +ALTER TABLE transactions ADD COLUMN chain_id TEXT REFERENCES chains(chain_id); +-- Generic on-chain tx hash column, mirroring `stellar_tx_hash`. `operation_index` is already +-- chain-agnostic in shape (it doubles as the EVM log index) and needs no new column, only the +-- re-scoped uniqueness added in later phases of this migration set. +ALTER TABLE transactions ADD COLUMN tx_hash TEXT; diff --git a/crates/store/migrations/0023_backfill_wallets_chain_id.sql b/crates/store/migrations/0023_backfill_wallets_chain_id.sql new file mode 100644 index 0000000..12f25aa --- /dev/null +++ b/crates/store/migrations/0023_backfill_wallets_chain_id.sql @@ -0,0 +1,12 @@ +-- Multi-chain schema, phase 3a: backfill `wallets.chain_id` from the legacy `network` column. +-- +-- `wallets` is a low-cardinality operational table (one row per master/custody wallet, not per +-- customer), so a single UPDATE is safe here — unlike `addresses`/`transactions`, it does not need +-- batching. This mirrors the precedent in 0008_scheme_version.sql (a plain backfill UPDATE for +-- this same table). +UPDATE wallets +SET chain_id = CASE network + WHEN 'mainnet' THEN 'stellar:pubnet' + WHEN 'testnet' THEN 'stellar:testnet' + END +WHERE chain_id IS NULL; diff --git a/crates/store/migrations/0024_backfill_addresses_chain_id.sql b/crates/store/migrations/0024_backfill_addresses_chain_id.sql new file mode 100644 index 0000000..9e86d10 --- /dev/null +++ b/crates/store/migrations/0024_backfill_addresses_chain_id.sql @@ -0,0 +1,48 @@ +-- no-transaction +-- Multi-chain schema, phase 3b: backfill `addresses.chain_id` and `addresses.deposit_address`. +-- +-- `addresses` is per-customer and can be large in production, so — unlike the wallets backfill — +-- this runs in small committed batches instead of one long UPDATE. Each batch commits +-- independently (this file is `-- no-transaction`, so it is not wrapped in sqlx's usual +-- per-migration transaction, which is what allows a bare COMMIT inside the DO block below): no +-- single transaction holds row locks or accumulates WAL for the whole table, and the loop is +-- resumable — if the process is interrupted, restarting `store.migrate()` just continues from the +-- first still-NULL row instead of redoing already-migrated batches. +-- +-- Must be the only statement in this file: bundling more statements alongside it would make +-- Postgres wrap them all in one implicit transaction, and COMMIT is not allowed inside a DO block +-- that isn't already the sole, top-level statement of its transaction. +-- +-- Batch boundaries are tracked as a UUID array rather than `max(id)`: Postgres has no built-in +-- MAX/MIN aggregate for uuid (it's comparable via operators for ORDER BY/WHERE, just not +-- aggregatable), so the last id in each ordered batch is read off the end of an explicitly +-- ordered `array_agg` instead. +DO $$ +DECLARE + batch_size CONSTANT INT := 5000; + last_id UUID := '00000000-0000-0000-0000-000000000000'; + batch_ids UUID[]; + rows_in_batch INT; +BEGIN + LOOP + SELECT array_agg(id ORDER BY id) INTO batch_ids + FROM ( + SELECT id FROM addresses + WHERE id > last_id AND chain_id IS NULL + ORDER BY id + LIMIT batch_size + ) s; + + rows_in_batch := coalesce(array_length(batch_ids, 1), 0); + EXIT WHEN rows_in_batch = 0; + + UPDATE addresses a + SET chain_id = w.chain_id, + deposit_address = a.muxed_address + FROM wallets w + WHERE a.id = ANY(batch_ids) AND a.wallet_id = w.id; + + last_id := batch_ids[array_upper(batch_ids, 1)]; + COMMIT; + END LOOP; +END $$; diff --git a/crates/store/migrations/0025_backfill_transactions_chain_id.sql b/crates/store/migrations/0025_backfill_transactions_chain_id.sql new file mode 100644 index 0000000..c8ca7e1 --- /dev/null +++ b/crates/store/migrations/0025_backfill_transactions_chain_id.sql @@ -0,0 +1,37 @@ +-- no-transaction +-- Multi-chain schema, phase 3c: backfill `transactions.chain_id` and `transactions.tx_hash`. +-- +-- Same batched, resumable, single-statement pattern as 0024_backfill_addresses_chain_id.sql — +-- see that file's header for the full rationale (including why batch boundaries are tracked via +-- `array_agg` instead of `max(id)`). `transactions` is the table explicitly called out in #214 as +-- needing a production-scale-safe backfill (append-only ledger, unbounded growth), so this is the +-- one most worth batching correctly. +DO $$ +DECLARE + batch_size CONSTANT INT := 5000; + last_id UUID := '00000000-0000-0000-0000-000000000000'; + batch_ids UUID[]; + rows_in_batch INT; +BEGIN + LOOP + SELECT array_agg(id ORDER BY id) INTO batch_ids + FROM ( + SELECT id FROM transactions + WHERE id > last_id AND chain_id IS NULL + ORDER BY id + LIMIT batch_size + ) s; + + rows_in_batch := coalesce(array_length(batch_ids, 1), 0); + EXIT WHEN rows_in_batch = 0; + + UPDATE transactions t + SET chain_id = w.chain_id, + tx_hash = t.stellar_tx_hash + FROM wallets w + WHERE t.id = ANY(batch_ids) AND t.wallet_id = w.id; + + last_id := batch_ids[array_upper(batch_ids, 1)]; + COMMIT; + END LOOP; +END $$; diff --git a/crates/store/migrations/0026_chain_id_not_null_check.sql b/crates/store/migrations/0026_chain_id_not_null_check.sql new file mode 100644 index 0000000..a009949 --- /dev/null +++ b/crates/store/migrations/0026_chain_id_not_null_check.sql @@ -0,0 +1,21 @@ +-- Multi-chain schema, phase 4a: add the NOT NULL guarantee as a `NOT VALID` CHECK constraint. +-- +-- `ADD CONSTRAINT ... CHECK (col IS NOT NULL) NOT VALID` takes ACCESS EXCLUSIVE but only for the +-- instant it takes to register the constraint in the catalog — it does not scan existing rows, so +-- it is safe on a production-sized table. The scan happens next, in +-- 0027_validate_chain_id_not_null.sql, under a much weaker lock that does not block reads/writes. +-- +-- These four columns are exactly the ones with no NULL producer left after phase 3: every existing +-- row was backfilled, and every new row (via octo_store::Store) is written with chain_id / +-- deposit_address populated already. `transactions.tx_hash` stays nullable — it mirrors +-- `stellar_tx_hash`, which is legitimately NULL for pending/withdrawal rows with no on-chain hash +-- yet, and the anti-double-credit index already accounts for that with a partial `WHERE tx_hash IS +-- NOT NULL` (see 0032_uq_tx_onchain_chain_concurrent.sql). +ALTER TABLE wallets + ADD CONSTRAINT chk_wallets_chain_id_not_null CHECK (chain_id IS NOT NULL) NOT VALID; +ALTER TABLE addresses + ADD CONSTRAINT chk_addresses_chain_id_not_null CHECK (chain_id IS NOT NULL) NOT VALID; +ALTER TABLE addresses + ADD CONSTRAINT chk_addresses_deposit_address_not_null CHECK (deposit_address IS NOT NULL) NOT VALID; +ALTER TABLE transactions + ADD CONSTRAINT chk_transactions_chain_id_not_null CHECK (chain_id IS NOT NULL) NOT VALID; diff --git a/crates/store/migrations/0027_validate_chain_id_not_null.sql b/crates/store/migrations/0027_validate_chain_id_not_null.sql new file mode 100644 index 0000000..4a5f15c --- /dev/null +++ b/crates/store/migrations/0027_validate_chain_id_not_null.sql @@ -0,0 +1,13 @@ +-- Multi-chain schema, phase 4b: validate the NOT VALID constraints added in +-- 0026_chain_id_not_null_check.sql. +-- +-- VALIDATE CONSTRAINT takes SHARE UPDATE EXCLUSIVE, which conflicts only with other DDL (and +-- VACUUM FULL) — ordinary reads and writes proceed throughout the scan. This is deliberately a +-- separate migration file from 0026: sqlx runs each migration in its own transaction, and running +-- the NOT VALID add and the VALIDATE in the same transaction would hold the ADD's ACCESS EXCLUSIVE +-- lock for the whole scan (locks are held for the transaction's duration, not the statement's), +-- defeating the point of NOT VALID entirely. +ALTER TABLE wallets VALIDATE CONSTRAINT chk_wallets_chain_id_not_null; +ALTER TABLE addresses VALIDATE CONSTRAINT chk_addresses_chain_id_not_null; +ALTER TABLE addresses VALIDATE CONSTRAINT chk_addresses_deposit_address_not_null; +ALTER TABLE transactions VALIDATE CONSTRAINT chk_transactions_chain_id_not_null; diff --git a/crates/store/migrations/0028_chain_id_set_not_null.sql b/crates/store/migrations/0028_chain_id_set_not_null.sql new file mode 100644 index 0000000..5c928de --- /dev/null +++ b/crates/store/migrations/0028_chain_id_set_not_null.sql @@ -0,0 +1,16 @@ +-- Multi-chain schema, phase 4c: promote the validated CHECK constraints to real column-level +-- NOT NULL, then drop the now-redundant CHECK. +-- +-- On Postgres 12+, `SET NOT NULL` can use an already-validated `CHECK (col IS NOT NULL)` +-- constraint as proof and skip its own table scan — so despite looking like a heavyweight +-- operation, this is metadata-only and fast, even on a production-sized `transactions` table. +ALTER TABLE wallets ALTER COLUMN chain_id SET NOT NULL; +ALTER TABLE wallets DROP CONSTRAINT chk_wallets_chain_id_not_null; + +ALTER TABLE addresses ALTER COLUMN chain_id SET NOT NULL; +ALTER TABLE addresses DROP CONSTRAINT chk_addresses_chain_id_not_null; +ALTER TABLE addresses ALTER COLUMN deposit_address SET NOT NULL; +ALTER TABLE addresses DROP CONSTRAINT chk_addresses_deposit_address_not_null; + +ALTER TABLE transactions ALTER COLUMN chain_id SET NOT NULL; +ALTER TABLE transactions DROP CONSTRAINT chk_transactions_chain_id_not_null; diff --git a/crates/store/migrations/0029_idx_addresses_chain_concurrent.sql b/crates/store/migrations/0029_idx_addresses_chain_concurrent.sql new file mode 100644 index 0000000..7f19af8 --- /dev/null +++ b/crates/store/migrations/0029_idx_addresses_chain_concurrent.sql @@ -0,0 +1,5 @@ +-- no-transaction +-- Multi-chain schema, phase 5a: supporting index for per-chain address lookups. +-- CONCURRENTLY avoids the SHARE lock a plain CREATE INDEX would take (which blocks writes for the +-- duration of the build). Must be the only statement in this file — see 0024's header for why. +CREATE INDEX CONCURRENTLY idx_addresses_chain ON addresses(chain_id); diff --git a/crates/store/migrations/0030_uq_addresses_chain_deposit_concurrent.sql b/crates/store/migrations/0030_uq_addresses_chain_deposit_concurrent.sql new file mode 100644 index 0000000..50bcca0 --- /dev/null +++ b/crates/store/migrations/0030_uq_addresses_chain_deposit_concurrent.sql @@ -0,0 +1,13 @@ +-- no-transaction +-- Multi-chain schema, phase 5b: `UNIQUE (muxed_address)` -> `UNIQUE (chain_id, deposit_address)`. +-- +-- 0002_horizon_op_id.sql already dropped the old global `UNIQUE(muxed_address)` (it was redundant +-- with `UNIQUE(wallet_id, muxed_id)` at the time), so there is no legacy constraint to retire here +-- — this purely *adds* the correct chain-scoped invariant: a deposit address must be unique within +-- its chain (not globally), which is the wrong assumption for EVM where addresses are only unique +-- per chain. Partial (`WHERE deposit_address IS NOT NULL`) so it stays valid mid-backfill; by the +-- time this runs (phase 5, after phase 4's NOT NULL enforcement) the predicate is always true, but +-- keeping it costs nothing and documents the column's history. +CREATE UNIQUE INDEX CONCURRENTLY uq_addresses_chain_deposit + ON addresses (chain_id, deposit_address) + WHERE deposit_address IS NOT NULL; diff --git a/crates/store/migrations/0031_idx_tx_chain_concurrent.sql b/crates/store/migrations/0031_idx_tx_chain_concurrent.sql new file mode 100644 index 0000000..22671c8 --- /dev/null +++ b/crates/store/migrations/0031_idx_tx_chain_concurrent.sql @@ -0,0 +1,3 @@ +-- no-transaction +-- Multi-chain schema, phase 5c: supporting index for per-chain transaction lookups. +CREATE INDEX CONCURRENTLY idx_tx_chain ON transactions(chain_id); diff --git a/crates/store/migrations/0032_uq_tx_onchain_chain_concurrent.sql b/crates/store/migrations/0032_uq_tx_onchain_chain_concurrent.sql new file mode 100644 index 0000000..49890dd --- /dev/null +++ b/crates/store/migrations/0032_uq_tx_onchain_chain_concurrent.sql @@ -0,0 +1,17 @@ +-- no-transaction +-- Multi-chain schema, phase 5d: the chain-scoped anti-double-credit guard. +-- +-- This is the core fix in #214: the old `uq_tx_onchain` was UNIQUE(stellar_tx_hash, +-- operation_index) with no chain dimension, so it silently assumed a tx hash is globally unique — +-- true for a single Stellar network, false in general (the same signed transaction, or simply the +-- same hash value, is not guaranteed unique across two independent chains). Left alone, that old +-- index would eventually reject a legitimate deposit on chain B because chain A already used the +-- same (tx_hash, operation_index) pair — a dropped-deposit bug, not a double-credit one, but still +-- a fund-safety bug (see the regression test `same_tx_hash_different_chain_is_accepted` in +-- store_tests.rs for the corresponding double-credit-must-still-be-rejected-within-a-chain case). +-- +-- Built CONCURRENTLY so it does not block writes to `transactions` while it scans; the old index +-- is only dropped in 0033, once this one is confirmed built and valid. +CREATE UNIQUE INDEX CONCURRENTLY uq_tx_onchain_chain + ON transactions (chain_id, tx_hash, operation_index) + WHERE tx_hash IS NOT NULL; diff --git a/crates/store/migrations/0033_drop_legacy_uq_tx_onchain_concurrent.sql b/crates/store/migrations/0033_drop_legacy_uq_tx_onchain_concurrent.sql new file mode 100644 index 0000000..5e39394 --- /dev/null +++ b/crates/store/migrations/0033_drop_legacy_uq_tx_onchain_concurrent.sql @@ -0,0 +1,9 @@ +-- no-transaction +-- Multi-chain schema, phase 5e: retire the old, non-chain-scoped anti-double-credit index now +-- that `uq_tx_onchain_chain` (0032) is built and live. +-- +-- sqlx applies migrations strictly in version order and stops at the first failure, so this file +-- cannot run unless 0032 already committed successfully — the ordering guarantee the "only dropped +-- after the new one is live" requirement in #214 asks for. DROP INDEX CONCURRENTLY (rather than a +-- plain DROP INDEX) avoids even the brief ACCESS EXCLUSIVE a normal drop would take. +DROP INDEX CONCURRENTLY IF EXISTS uq_tx_onchain; diff --git a/crates/store/src/lib.rs b/crates/store/src/lib.rs index 97f981a..f1aa6ab 100644 --- a/crates/store/src/lib.rs +++ b/crates/store/src/lib.rs @@ -1,14 +1,17 @@ //! Postgres persistence for octo (sqlx). //! -//! Tables: `wallets`, `addresses`, `transactions`, `withdrawals`, `webhook_endpoints`, -//! `webhook_deliveries`, `ingest_cursor` — see `migrations/0001_init.sql`. +//! Tables: `chains`, `wallets`, `addresses`, `transactions`, `withdrawals`, `webhook_endpoints`, +//! `webhook_deliveries`, `ingest_cursor` — see `migrations/0001_init.sql` and, for the multi-chain +//! generalization, `migrations/0021_chains_registry.sql` onward. //! //! Security-relevant guarantees implemented here (see `docs/threat-model.md`): //! - All queries are parameterized (no string-built SQL) → no SQL injection. //! - [`Store::allocate_address`] increments the per-wallet muxed-id counter **atomically** inside a //! transaction, so concurrent address creation can't collide or reuse an id. -//! - [`Store::record_deposit`] is **idempotent** on the immutable `(tx_hash, operation_index)` -//! unique index, so a replayed/reorged Horizon event cannot double-credit. +//! - [`Store::record_deposit`] is **idempotent** on the immutable `(chain_id, tx_hash, +//! operation_index)` unique index, so a replayed/reorged Horizon event cannot double-credit — +//! and, since #214, a legitimate deposit on one chain can never collide with one on another +//! chain that happens to reuse the same `(tx_hash, operation_index)` pair. //! - [`Store::create_withdrawal`] is idempotent on `(wallet_id, idempotency_key)`. #![forbid(unsafe_code)] @@ -29,6 +32,26 @@ use uuid::Uuid; /// Embedded migrations, applied by [`Store::migrate`]. pub static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations"); +/// Process-wide serialization for [`Store::migrate`] calls. See the doc comment on `migrate` for +/// why this exists. +static MIGRATE_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + +/// Bridge from the legacy Stellar-only `network` column to a `chain_id` in the `chains` registry +/// (see `migrations/0021_chains_registry.sql`). +/// +/// Every `Store` write path that inserts or updates a `wallets`/`addresses`/`transactions` row +/// still takes `network` (or derives from a wallet that carries one) rather than a `chain_id` +/// directly — callers throughout `octo-api`/`octo-ingest` are not multi-chain-aware yet, and +/// making them pass a chain id explicitly is the job of the `octo-chain` adapter trait (#213) and +/// the EVM issues it unblocks (#215/#220/#223), not this schema migration. Until that lands, this +/// is the single place that maps one to the other, so every row stays internally consistent. +pub fn stellar_chain_id_for_network(network: &str) -> &'static str { + match network { + "mainnet" => "stellar:pubnet", + _ => "stellar:testnet", + } +} + /// A handle to the database (cloneable; wraps a connection pool). #[derive(Clone)] pub struct Store { @@ -90,6 +113,27 @@ impl Store { /// Apply all pending migrations. pub async fn migrate(&self) -> Result<(), StoreError> { + // Serialize concurrent `migrate()` calls *within this process* before they even reach + // sqlx's own cross-process advisory lock. + // + // sqlx already guards concurrent migrators with a session-held `pg_advisory_lock`, which + // is normally enough on its own. But migrations that use `CREATE INDEX CONCURRENTLY` / + // `VALIDATE CONSTRAINT` (see migrations/0027, 0029-0033) wait for *every* other + // in-progress transaction on the server to finish — including a second caller that is + // merely blocked waiting to acquire sqlx's advisory lock, since that wait itself counts + // as an open transaction. That produces a genuine deadlock: the blocked waiter holds a + // transaction the CONCURRENTLY/VALIDATE statement must wait out, while that statement + // holds the advisory lock the waiter needs. Observed directly in this crate's own test + // suite, where many `#[tokio::test]`s each call `Store::connect(..).migrate()` + // concurrently against one shared database. + // + // This lock only protects same-process callers (e.g. many parallel tests, or multiple + // Store handles in one binary) — it cannot serialize independent OS processes. A + // multi-replica rolling deploy that lets every replica call `migrate()` at boot has the + // same underlying hazard whenever a CONCURRENTLY/VALIDATE migration is pending; the + // operational fix there is to apply migrations once (`just migrate`) before scaling up + // new replicas, same as any other zero-downtime CONCURRENTLY rollout. + let _guard = MIGRATE_LOCK.lock().await; MIGRATOR.run(&self.pool).await?; Ok(()) } @@ -352,13 +396,14 @@ impl Store { sqlx::query_as::<_, Wallet>( r#" INSERT INTO wallets - (network, stellar_account_g, sealed_ciphertext, sealed_nonce, sealed_salt, - sealed_scheme, label, user_id, description, custody) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'server') + (network, chain_id, stellar_account_g, sealed_ciphertext, sealed_nonce, + sealed_salt, sealed_scheme, label, user_id, description, custody) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'server') RETURNING * "#, ) .bind(new.network) + .bind(stellar_chain_id_for_network(new.network)) .bind(new.stellar_account_g) .bind(new.sealed_ciphertext) .bind(new.sealed_nonce) @@ -414,13 +459,14 @@ impl Store { sqlx::query_as::<_, Wallet>( r#" INSERT INTO wallets - (network, stellar_account_g, label, user_id, description, custody, + (network, chain_id, stellar_account_g, label, user_id, description, custody, encrypted_backup) - VALUES ($1, $2, $3, $4, $5, 'client', $6) + VALUES ($1, $2, $3, $4, $5, $6, 'client', $7) RETURNING * "#, ) .bind(new.network) + .bind(stellar_chain_id_for_network(new.network)) .bind(new.stellar_account_g) .bind(new.label) .bind(new.user_id) @@ -666,9 +712,10 @@ impl Store { ) -> Result { let mut tx = self.pool.begin().await?; - // Lock the wallet row and read+bump the counter. - let next_id: i64 = - sqlx::query_scalar("SELECT next_muxed_id FROM wallets WHERE id = $1 FOR UPDATE") + // Lock the wallet row and read+bump the counter (also grabbing chain_id, so the new + // address row is always consistent with its parent wallet's chain). + let (next_id, chain_id): (i64, String) = + sqlx::query_as("SELECT next_muxed_id, chain_id FROM wallets WHERE id = $1 FOR UPDATE") .bind(wallet_id) .fetch_optional(&mut *tx) .await? @@ -684,12 +731,15 @@ impl Store { let address = sqlx::query_as::<_, Address>( r#" - INSERT INTO addresses (wallet_id, muxed_id, muxed_address, customer_ref, metadata) - VALUES ($1, $2, $3, $4, $5) + INSERT INTO addresses + (wallet_id, chain_id, muxed_id, muxed_address, deposit_address, customer_ref, + metadata) + VALUES ($1, $2, $3, $4, $4, $5, $6) RETURNING * "#, ) .bind(wallet_id) + .bind(chain_id) .bind(next_id) .bind(&muxed_address) .bind(customer_ref) @@ -791,10 +841,12 @@ impl Store { let result = sqlx::query_as::<_, Transaction>( r#" INSERT INTO transactions - (wallet_id, address_id, direction, asset_code, asset_issuer, amount_stroops, - source_account, destination_account, stellar_tx_hash, operation_index, - horizon_op_id, ledger, memo_id, status) - VALUES ($1, $2, 'deposit', $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, 'confirmed') + (wallet_id, chain_id, address_id, direction, asset_code, asset_issuer, + amount_stroops, source_account, destination_account, stellar_tx_hash, tx_hash, + operation_index, horizon_op_id, ledger, memo_id, status) + VALUES + ($1, (SELECT chain_id FROM wallets WHERE id = $1), $2, 'deposit', $3, $4, $5, $6, + $7, $8, $8, $9, $10, $11, $12, 'confirmed') RETURNING * "#, ) @@ -924,9 +976,11 @@ impl Store { let row = sqlx::query_as::<_, Transaction>( r#" INSERT INTO transactions - (wallet_id, direction, asset_code, asset_issuer, amount_stroops, - source_account, destination_account, stellar_tx_hash, status) - VALUES ($1, 'withdrawal', $2, $3, $4, $5, $6, $7, $8) + (wallet_id, chain_id, direction, asset_code, asset_issuer, amount_stroops, + source_account, destination_account, stellar_tx_hash, tx_hash, status) + VALUES + ($1, (SELECT chain_id FROM wallets WHERE id = $1), 'withdrawal', $2, $3, $4, $5, + $6, $7, $7, $8) RETURNING * "#, ) diff --git a/crates/store/src/models.rs b/crates/store/src/models.rs index 8f8f8a5..3ff39f7 100644 --- a/crates/store/src/models.rs +++ b/crates/store/src/models.rs @@ -21,6 +21,11 @@ use uuid::Uuid; pub struct Wallet { pub id: Uuid, pub network: String, + /// CAIP-2-shaped chain slug (see `migrations/0021_chains_registry.sql`), e.g. + /// `stellar:pubnet`. Derived from `network` at write time until every caller passes a chain id + /// directly (see `octo_store::stellar_chain_id_for_network`); kept in lockstep with `network` + /// by every `Store` write path, so the two never disagree in practice. + pub chain_id: String, pub stellar_account_g: String, pub sealed_ciphertext: Option>, pub sealed_nonce: Option>, @@ -51,8 +56,18 @@ impl Wallet { pub struct Address { pub id: Uuid, pub wallet_id: Uuid, + /// CAIP-2-shaped chain slug; always equal to the parent wallet's `chain_id`. + pub chain_id: String, pub muxed_id: i64, pub muxed_address: String, + /// Generic on-chain deposit address, unique within `chain_id` + /// (`uq_addresses_chain_deposit`). Mirrors `muxed_address` for Stellar rows; for a future EVM + /// adapter this is the actual HD-derived `0x...` address. + pub deposit_address: String, + /// BIP-44-style HD derivation index, for chains (EVM) that derive one address per index. + /// Always `None` for Stellar, which routes by `muxed_id` instead — that's an off-chain id, + /// not a key-derivation index. + pub derivation_index: Option, pub customer_ref: Option, pub metadata: serde_json::Value, pub created_at: DateTime, @@ -63,6 +78,8 @@ pub struct Address { pub struct Transaction { pub id: Uuid, pub wallet_id: Uuid, + /// CAIP-2-shaped chain slug; always equal to the parent wallet's `chain_id`. + pub chain_id: String, pub address_id: Option, pub direction: String, pub asset_code: String, @@ -71,6 +88,11 @@ pub struct Transaction { pub source_account: Option, pub destination_account: Option, pub stellar_tx_hash: Option, + /// Generic on-chain tx hash, mirroring `stellar_tx_hash`. Together with `chain_id` and + /// `operation_index` this is the anti-double-credit dedup key (`uq_tx_onchain_chain`). + pub tx_hash: Option, + /// Operation index within the transaction (Stellar) or log index within the tx receipt + /// (EVM) — the concept generalizes without needing a new column. pub operation_index: Option, pub horizon_op_id: Option, pub ledger: Option, diff --git a/crates/store/tests/store_tests.rs b/crates/store/tests/store_tests.rs index 9b047f6..6a48512 100644 --- a/crates/store/tests/store_tests.rs +++ b/crates/store/tests/store_tests.rs @@ -10,6 +10,7 @@ use octo_store::{ NewDeposit, NewPaymentLink, NewSponsoredTx, NewWallet, NewWithdrawal, Store, StoreError, }; +use sqlx::Connection; use std::sync::Once; use uuid::Uuid; @@ -42,10 +43,17 @@ async fn store() -> Option { /// Create a throwaway wallet with a unique account id (so tests don't collide). async fn fresh_wallet(store: &Store) -> Uuid { + fresh_wallet_on_network(store, "testnet").await +} + +/// Like [`fresh_wallet`], but on a caller-chosen `network` — used by tests that need two wallets +/// on two different chains (`network` drives `chain_id` via +/// `octo_store::stellar_chain_id_for_network`). +async fn fresh_wallet_on_network(store: &Store, network: &'static str) -> Uuid { let acct = format!("G{}", Uuid::new_v4().simple()); // unique, not a real strkey (fine for store tests) let w = store .create_wallet(NewWallet { - network: "testnet", + network, stellar_account_g: &acct, sealed_ciphertext: b"ciphertext", sealed_nonce: b"nonce12bytes", @@ -67,6 +75,19 @@ async fn create_and_get_wallet() { let w = store.get_wallet(id).await.expect("get"); assert_eq!(w.network, "testnet"); assert_eq!(w.next_muxed_id, 1); + assert_eq!( + w.chain_id, "stellar:testnet", + "chain_id must be derived from network" + ); +} + +#[tokio::test] +async fn create_wallet_maps_mainnet_network_to_the_pubnet_chain_id() { + let Some(store) = store().await else { return }; + let id = fresh_wallet_on_network(&store, "mainnet").await; + let w = store.get_wallet(id).await.expect("get"); + assert_eq!(w.network, "mainnet"); + assert_eq!(w.chain_id, "stellar:pubnet"); } #[tokio::test] @@ -99,6 +120,12 @@ async fn allocate_address_increments_atomically() { assert_eq!(a.muxed_id, 1); assert_eq!(b.muxed_id, 2); assert_ne!(a.muxed_address, b.muxed_address); + assert_eq!(a.chain_id, "stellar:testnet"); + assert_eq!( + a.deposit_address, a.muxed_address, + "deposit_address must mirror muxed_address for Stellar rows" + ); + assert_eq!(a.derivation_index, None, "Stellar rows don't use this"); let list = store .list_addresses(wallet_id, 100, None) @@ -184,6 +211,87 @@ async fn different_op_index_same_tx_is_distinct() { ); } +/// Regression test for #214: the anti-double-credit index must be scoped by `chain_id`, not just +/// `(tx_hash, operation_index)`. Before the fix, `uq_tx_onchain` was a bare +/// UNIQUE(stellar_tx_hash, operation_index) — a legitimate deposit on chain B with the same +/// `(tx_hash, operation_index)` as one already recorded on chain A would have been silently +/// rejected as a duplicate (a dropped-deposit bug). This proves both halves of the fix: the +/// same pair is now accepted across two different chains, and still rejected within one chain. +#[tokio::test] +async fn same_tx_hash_and_operation_index_is_accepted_across_chains_but_not_within_one() { + let Some(store) = store().await else { return }; + // Two wallets on two different chains (mainnet -> stellar:pubnet, testnet -> stellar:testnet). + let mainnet_wallet = fresh_wallet_on_network(&store, "mainnet").await; + let testnet_wallet = fresh_wallet_on_network(&store, "testnet").await; + + let shared_hash = format!("shared-{}", Uuid::new_v4().simple()); + let dep_for = |wallet_id: Uuid, horizon_suffix: &str| NewDeposit { + wallet_id, + address_id: None, + asset_code: "native".into(), + asset_issuer: None, + amount_stroops: 1, + source_account: None, + destination_account: None, + stellar_tx_hash: shared_hash.clone(), + operation_index: 0, + horizon_op_id: format!("{shared_hash}-{horizon_suffix}"), + ledger: None, + memo_id: None, + }; + + // Same (tx_hash, operation_index) on chain A (mainnet) ... + let on_mainnet = store + .record_deposit(&dep_for(mainnet_wallet, "mainnet")) + .await + .expect("mainnet deposit"); + assert!( + on_mainnet.is_some(), + "first deposit on chain A must be recorded" + ); + + // ... and again on chain B (testnet) — must NOT be treated as a duplicate of chain A's row. + let on_testnet = store + .record_deposit(&dep_for(testnet_wallet, "testnet")) + .await + .expect("testnet deposit"); + assert!( + on_testnet.is_some(), + "the same (tx_hash, operation_index) on a DIFFERENT chain must be accepted, \ + not treated as a cross-chain duplicate" + ); + + // A genuine repeat within the SAME chain must still be rejected (the invariant we're + // re-scoping, not removing). + let repeat_on_mainnet = store + .record_deposit(&dep_for(mainnet_wallet, "mainnet-repeat")) + .await + .expect("repeat within chain A"); + assert!( + repeat_on_mainnet.is_none(), + "a repeat of (tx_hash, operation_index) within the SAME chain must still be rejected" + ); + + assert_eq!( + store + .list_transactions(mainnet_wallet, 100, None) + .await + .unwrap() + .len(), + 1, + "chain A must have exactly one ledger entry, not two" + ); + assert_eq!( + store + .list_transactions(testnet_wallet, 100, None) + .await + .unwrap() + .len(), + 1, + "chain B's deposit must be its own, independent ledger entry" + ); +} + #[tokio::test] async fn sum_deposits_for_address_totals_only_that_addresss_confirmed_deposits() { let Some(store) = store().await else { return }; @@ -850,13 +958,16 @@ async fn migrate_applies_exactly_the_expected_version_set() { .expect("query _sqlx_migrations"); versions.sort_unstable(); - // One version per file under crates/store/migrations/, 0001_init.sql .. 0020. + // One version per file under crates/store/migrations/, 0001_init.sql .. 0033. // Guards against silent version collisions — sqlx keys migrations by version, so a repeated // number means only one of the colliding pair actually ran. assert_eq!( versions, - vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20], - "expected exactly the twenty known migrations to be recorded as applied" + vec![ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, + 25, 26, 27, 28, 29, 30, 31, 32, 33 + ], + "expected exactly the thirty-three known migrations to be recorded as applied" ); } @@ -1200,3 +1311,414 @@ async fn mark_polled_creates_and_updates_the_cursor_row() { "mark_polled must not fabricate a cursor position" ); } + +// --- multi-chain migration round-trip (#214) ------------------------------------------------- +// +// The tests below don't use the shared `store()` database (that one is already fully migrated +// through 0033 by the time any test runs). Instead they bootstrap a *fresh, scratch* database, +// apply only the pre-#214 migrations (0001..0020) to reproduce the exact schema shape that exists +// in production today, seed it with representative Stellar rows, then apply 0021..0033 and assert +// every row survived intact and every invariant now holds — the "zero data loss" and "every +// invariant still holds" deliverable called out in #214. + +/// Legacy (pre-#214) migrations, in order — the schema shape live in production today. +const LEGACY_MIGRATIONS: &[&str] = &[ + include_str!("../migrations/0001_init.sql"), + include_str!("../migrations/0002_horizon_op_id.sql"), + include_str!("../migrations/0003_users.sql"), + include_str!("../migrations/0004_wallet_owner.sql"), + include_str!("../migrations/0005_api_keys.sql"), + include_str!("../migrations/0006_audit_logs.sql"), + include_str!("../migrations/0007_gas_sponsorship.sql"), + include_str!("../migrations/0008_scheme_version.sql"), + include_str!("../migrations/0009_token_denylist.sql"), + include_str!("../migrations/0010_sponsored_tx_status_index.sql"), + include_str!("../migrations/0011_sponsored_and_audit_indexing.sql"), + include_str!("../migrations/0012_client_custody.sql"), + include_str!("../migrations/0013_withdrawal_allowlist.sql"), + include_str!("../migrations/0014_payment_links.sql"), + include_str!("../migrations/0015_payment_intent_address.sql"), + include_str!("../migrations/0016_ingest_last_polled.sql"), + include_str!("../migrations/0017_payment_link_redirect_url.sql"), + include_str!("../migrations/0018_payment_status_expansion.sql"), + include_str!("../migrations/0019_email_otp.sql"), + include_str!("../migrations/0020_username.sql"), +]; + +/// The #214 multi-chain migrations, in order. +const MULTI_CHAIN_MIGRATIONS: &[&str] = &[ + include_str!("../migrations/0021_chains_registry.sql"), + include_str!("../migrations/0022_chain_scoped_columns.sql"), + include_str!("../migrations/0023_backfill_wallets_chain_id.sql"), + include_str!("../migrations/0024_backfill_addresses_chain_id.sql"), + include_str!("../migrations/0025_backfill_transactions_chain_id.sql"), + include_str!("../migrations/0026_chain_id_not_null_check.sql"), + include_str!("../migrations/0027_validate_chain_id_not_null.sql"), + include_str!("../migrations/0028_chain_id_set_not_null.sql"), + include_str!("../migrations/0029_idx_addresses_chain_concurrent.sql"), + include_str!("../migrations/0030_uq_addresses_chain_deposit_concurrent.sql"), + include_str!("../migrations/0031_idx_tx_chain_concurrent.sql"), + include_str!("../migrations/0032_uq_tx_onchain_chain_concurrent.sql"), + include_str!("../migrations/0033_drop_legacy_uq_tx_onchain_concurrent.sql"), +]; + +/// Create a throwaway scratch database on the same server as `DATABASE_URL` and return +/// `(admin_url pointed at the `postgres` maintenance db, scratch db's own URL, scratch db name)`. +async fn create_scratch_database(base_url: &str) -> (String, String, String) { + let scratch_db = format!("octo_migration_rt_{}", Uuid::new_v4().simple()); + let last_slash = base_url + .rfind('/') + .expect("DATABASE_URL must contain a path"); + let server_url = &base_url[..last_slash]; + let admin_url = format!("{server_url}/postgres"); + let scratch_url = format!("{server_url}/{scratch_db}"); + + let mut admin_conn = sqlx::PgConnection::connect(&admin_url) + .await + .expect("connect to maintenance db"); + sqlx::raw_sql(&format!("CREATE DATABASE {scratch_db}")) + .execute(&mut admin_conn) + .await + .expect("create scratch database"); + + (admin_url, scratch_url, scratch_db) +} + +/// Best-effort teardown: closes the pool, then drops the scratch database. Failures here don't +/// fail the test — leaked scratch databases are a local dev/CI-runner cleanup concern, not a +/// correctness one. +async fn drop_scratch_database(pool: sqlx::PgPool, admin_url: &str, scratch_db: &str) { + pool.close().await; + if let Ok(mut admin_conn) = sqlx::PgConnection::connect(admin_url).await { + let _ = sqlx::raw_sql(&format!( + "DROP DATABASE IF EXISTS {scratch_db} WITH (FORCE)" + )) + .execute(&mut admin_conn) + .await; + } +} + +#[tokio::test] +async fn migration_round_trip_preserves_pre_migration_stellar_data_and_invariants() { + let Some(base_url) = database_url() else { + eprintln!("SKIPPED: DATABASE_URL is not set."); + return; + }; + + let (admin_url, scratch_url, scratch_db) = create_scratch_database(&base_url).await; + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(5) + .connect(&scratch_url) + .await + .expect("connect to scratch database"); + + // --- Phase 1: reproduce the exact pre-#214 production schema. --- + for migration in LEGACY_MIGRATIONS { + sqlx::raw_sql(migration) + .execute(&pool) + .await + .expect("apply legacy migration"); + } + + // --- Phase 2: seed representative pre-migration Stellar rows. --- + // A server-custody mainnet wallet and a client-custody testnet wallet (covering both the + // NOT NULL sealed_* path and the nullable client-custody path), each with addresses and a mix + // of confirmed deposits, a null-hash pending withdrawal row, and null asset_issuer/ledger + // fields — the kinds of rows that actually exist in a production `transactions` table. + let mainnet_wallet: Uuid = sqlx::query_scalar( + r#" + INSERT INTO wallets + (network, stellar_account_g, sealed_ciphertext, sealed_nonce, sealed_salt, + sealed_scheme, next_muxed_id, label) + VALUES ('mainnet', $1, 'ct', 'n', 's', 1, 3, 'legacy mainnet wallet') + RETURNING id + "#, + ) + .bind(format!("G{}", Uuid::new_v4().simple())) + .fetch_one(&pool) + .await + .expect("seed mainnet wallet"); + + let testnet_wallet: Uuid = sqlx::query_scalar( + r#" + INSERT INTO wallets + (network, stellar_account_g, custody, encrypted_backup, next_muxed_id, label) + VALUES ('testnet', $1, 'client', 'opaque-blob', 2, 'legacy client wallet') + RETURNING id + "#, + ) + .bind(format!("G{}", Uuid::new_v4().simple())) + .fetch_one(&pool) + .await + .expect("seed testnet wallet"); + + let addr_a1: Uuid = sqlx::query_scalar( + "INSERT INTO addresses (wallet_id, muxed_id, muxed_address, customer_ref) + VALUES ($1, 1, $2, 'cust-a') RETURNING id", + ) + .bind(mainnet_wallet) + .bind(format!("M{}", Uuid::new_v4().simple())) + .fetch_one(&pool) + .await + .expect("seed address a1"); + + let addr_a2: Uuid = sqlx::query_scalar( + "INSERT INTO addresses (wallet_id, muxed_id, muxed_address, customer_ref) + VALUES ($1, 2, $2, 'cust-b') RETURNING id", + ) + .bind(mainnet_wallet) + .bind(format!("M{}", Uuid::new_v4().simple())) + .fetch_one(&pool) + .await + .expect("seed address a2"); + + let addr_b1: Uuid = sqlx::query_scalar( + "INSERT INTO addresses (wallet_id, muxed_id, muxed_address, customer_ref) + VALUES ($1, 1, $2, 'cust-c') RETURNING id", + ) + .bind(testnet_wallet) + .bind(format!("M{}", Uuid::new_v4().simple())) + .fetch_one(&pool) + .await + .expect("seed address b1"); + + let hash_1 = format!("hash-{}", Uuid::new_v4().simple()); + let hash_2 = format!("hash-{}", Uuid::new_v4().simple()); + let hash_3 = format!("hash-{}", Uuid::new_v4().simple()); + + sqlx::query( + r#" + INSERT INTO transactions + (wallet_id, address_id, direction, asset_code, amount_stroops, stellar_tx_hash, + operation_index, horizon_op_id, ledger, status) + VALUES ($1, $2, 'deposit', 'native', 10000000, $3, 0, $4, 100, 'confirmed') + "#, + ) + .bind(mainnet_wallet) + .bind(addr_a1) + .bind(&hash_1) + .bind(format!("{hash_1}-0")) + .execute(&pool) + .await + .expect("seed deposit 1"); + + sqlx::query( + r#" + INSERT INTO transactions + (wallet_id, address_id, direction, asset_code, asset_issuer, amount_stroops, + stellar_tx_hash, operation_index, horizon_op_id, status) + VALUES ($1, $2, 'deposit', 'USDC', 'GISSUER', 5000000, $3, 1, $4, 'confirmed') + "#, + ) + .bind(mainnet_wallet) + .bind(addr_a2) + .bind(&hash_2) + .bind(format!("{hash_2}-1")) + .execute(&pool) + .await + .expect("seed deposit 2"); + + sqlx::query( + r#" + INSERT INTO transactions + (wallet_id, address_id, direction, asset_code, amount_stroops, stellar_tx_hash, + operation_index, horizon_op_id, status) + VALUES ($1, $2, 'deposit', 'native', 2500000, $3, 0, $4, 'confirmed') + "#, + ) + .bind(testnet_wallet) + .bind(addr_b1) + .bind(&hash_3) + .bind(format!("{hash_3}-0")) + .execute(&pool) + .await + .expect("seed deposit 3"); + + // A withdrawal-only row: no tx hash yet (mirrors a pending payout before submission). + sqlx::query( + "INSERT INTO transactions (wallet_id, direction, asset_code, amount_stroops, status) + VALUES ($1, 'withdrawal', 'native', 1000000, 'pending')", + ) + .bind(mainnet_wallet) + .execute(&pool) + .await + .expect("seed pending withdrawal"); + + let pre_wallet_count: i64 = sqlx::query_scalar("SELECT count(*) FROM wallets") + .fetch_one(&pool) + .await + .unwrap(); + let pre_address_count: i64 = sqlx::query_scalar("SELECT count(*) FROM addresses") + .fetch_one(&pool) + .await + .unwrap(); + let pre_tx_count: i64 = sqlx::query_scalar("SELECT count(*) FROM transactions") + .fetch_one(&pool) + .await + .unwrap(); + + // --- Phase 3: apply the #214 multi-chain migrations. --- + for migration in MULTI_CHAIN_MIGRATIONS { + sqlx::raw_sql(migration) + .execute(&pool) + .await + .expect("apply multi-chain migration"); + } + + // --- Phase 4: zero data loss. --- + let post_wallet_count: i64 = sqlx::query_scalar("SELECT count(*) FROM wallets") + .fetch_one(&pool) + .await + .unwrap(); + let post_address_count: i64 = sqlx::query_scalar("SELECT count(*) FROM addresses") + .fetch_one(&pool) + .await + .unwrap(); + let post_tx_count: i64 = sqlx::query_scalar("SELECT count(*) FROM transactions") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(pre_wallet_count, post_wallet_count, "no wallets lost"); + assert_eq!(pre_address_count, post_address_count, "no addresses lost"); + assert_eq!(pre_tx_count, post_tx_count, "no transactions lost"); + + // --- Phase 5: every row backfilled correctly. --- + let (mainnet_chain, testnet_chain): (String, String) = ( + sqlx::query_scalar("SELECT chain_id FROM wallets WHERE id = $1") + .bind(mainnet_wallet) + .fetch_one(&pool) + .await + .unwrap(), + sqlx::query_scalar("SELECT chain_id FROM wallets WHERE id = $1") + .bind(testnet_wallet) + .fetch_one(&pool) + .await + .unwrap(), + ); + assert_eq!(mainnet_chain, "stellar:pubnet"); + assert_eq!(testnet_chain, "stellar:testnet"); + + let address_rows: Vec<(Uuid, String, String, String, Option)> = sqlx::query_as( + "SELECT id, chain_id, muxed_address, deposit_address, derivation_index FROM addresses", + ) + .fetch_all(&pool) + .await + .unwrap(); + assert_eq!(address_rows.len(), 3); + for (id, chain_id, muxed_address, deposit_address, derivation_index) in &address_rows { + let expected_chain = if [addr_a1, addr_a2].contains(id) { + &mainnet_chain + } else { + &testnet_chain + }; + assert_eq!( + chain_id, expected_chain, + "address chain_id must match its wallet's" + ); + assert_eq!( + deposit_address, muxed_address, + "deposit_address must mirror muxed_address for backfilled Stellar rows" + ); + assert!(derivation_index.is_none()); + } + + let tx_rows: Vec<(String, Option, Option)> = + sqlx::query_as("SELECT chain_id, stellar_tx_hash, tx_hash FROM transactions") + .fetch_all(&pool) + .await + .unwrap(); + assert_eq!(tx_rows.len(), 4); + for (chain_id, stellar_tx_hash, tx_hash) in &tx_rows { + assert!( + chain_id == &mainnet_chain || chain_id == &testnet_chain, + "every transaction must have a real chain_id" + ); + assert_eq!( + tx_hash, stellar_tx_hash, + "tx_hash must mirror stellar_tx_hash, including the NULL withdrawal row" + ); + } + assert!( + tx_rows.iter().any(|(_, hash, _)| hash.is_none()), + "the pending withdrawal's NULL hash must be preserved, not coerced to a value" + ); + + // --- Phase 6: NOT NULL is really enforced (not just true by coincidence of the seed data). --- + let non_nullable: Vec<(String, String)> = sqlx::query_as( + r#" + SELECT table_name, column_name FROM information_schema.columns + WHERE (table_name, column_name) IN ( + ('wallets', 'chain_id'), ('addresses', 'chain_id'), + ('addresses', 'deposit_address'), ('transactions', 'chain_id') + ) AND is_nullable = 'NO' + "#, + ) + .fetch_all(&pool) + .await + .unwrap(); + assert_eq!( + non_nullable.len(), + 4, + "all four chain-scoping columns must be NOT NULL after the migration set: {non_nullable:?}" + ); + + // --- Phase 7: the new chain-scoped unique indexes exist; the old global one is gone. --- + let index_names: Vec = + sqlx::query_scalar("SELECT indexname FROM pg_indexes WHERE tablename = 'transactions'") + .fetch_all(&pool) + .await + .unwrap(); + assert!(index_names.contains(&"uq_tx_onchain_chain".to_string())); + assert!( + !index_names.contains(&"uq_tx_onchain".to_string()), + "the old non-chain-scoped index must be dropped once the new one is live" + ); + let addr_index_names: Vec = + sqlx::query_scalar("SELECT indexname FROM pg_indexes WHERE tablename = 'addresses'") + .fetch_all(&pool) + .await + .unwrap(); + assert!(addr_index_names.contains(&"uq_addresses_chain_deposit".to_string())); + + // --- Phase 8: the live Store API still works end-to-end against the migrated-in-place schema + // (not just a from-scratch one) — allocate a new address and record a new deposit. + let store = Store::from_pool(pool.clone()); + let wid = mainnet_wallet.simple(); + let new_address = store + .allocate_address( + mainnet_wallet, + |id| Ok(format!("M{wid}-{id}")), + Some("post-migration-customer"), + serde_json::json!({}), + ) + .await + .expect("allocate address on migrated-in-place wallet"); + assert_eq!(new_address.chain_id, mainnet_chain); + assert_eq!( + new_address.muxed_id, 3, + "counter continued from the legacy next_muxed_id" + ); + + let new_dep = NewDeposit { + wallet_id: mainnet_wallet, + address_id: Some(new_address.id), + asset_code: "native".into(), + asset_issuer: None, + amount_stroops: 42, + source_account: None, + destination_account: None, + stellar_tx_hash: format!("post-migration-{}", Uuid::new_v4().simple()), + operation_index: 0, + horizon_op_id: format!("post-migration-{}", Uuid::new_v4().simple()), + ledger: None, + memo_id: None, + }; + let recorded = store + .record_deposit(&new_dep) + .await + .expect("record deposit on migrated-in-place wallet"); + assert!(recorded.is_some()); + assert_eq!(recorded.unwrap().chain_id, mainnet_chain); + + drop_scratch_database(pool, &admin_url, &scratch_db).await; +} diff --git a/docs/architecture.md b/docs/architecture.md index 281c910..8aec331 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -77,3 +77,107 @@ server, and it is confined to one crate: Keys are never written to disk or logs and are never persisted in derived form. Worst-case exposure of this key is the gas budget — never customer balances. + +## Data model: multi-chain (#214) + +The schema used to hard-code Stellar into column names, constraints, and unique indexes — +`wallets.network CHECK (network IN ('mainnet','testnet'))` had no chain dimension at all, and the +anti-double-credit guard was `UNIQUE(stellar_tx_hash, operation_index)` with no chain scoping, +which silently assumed a tx hash is globally unique (true for one Stellar network, false once a +second chain exists). `migrations/0021_chains_registry.sql` onward generalizes this to a `chains` +registry plus a `chain_id` column threaded through `wallets`, `addresses`, and `transactions`. + +```mermaid +erDiagram + chains ||--o{ wallets : "chain_id" + wallets ||--o{ addresses : "wallet_id" + wallets ||--o{ transactions : "wallet_id" + addresses |o--o{ transactions : "address_id" + chains ||--o{ addresses : "chain_id" + chains ||--o{ transactions : "chain_id" + + chains { + text chain_id PK "CAIP-2-shaped slug, e.g. stellar:pubnet" + text kind "stellar | evm" + text native_symbol + smallint native_decimals + integer confirmation_depth + boolean enabled + } + wallets { + uuid id PK + text network "legacy: mainnet | testnet" + text chain_id FK "NOT NULL, derived from network" + text stellar_account_g + bigint next_muxed_id + } + addresses { + uuid id PK + uuid wallet_id FK + text chain_id FK "NOT NULL, = parent wallet's chain_id" + bigint muxed_id "Stellar off-chain routing id" + text muxed_address "legacy" + text deposit_address "NOT NULL, generic; = muxed_address for Stellar" + bigint derivation_index "EVM HD index; null for Stellar" + } + transactions { + uuid id PK + uuid wallet_id FK + uuid address_id FK "nullable" + text chain_id FK "NOT NULL, = parent wallet's chain_id" + text stellar_tx_hash "legacy, nullable" + text tx_hash "generic, nullable, mirrors stellar_tx_hash" + integer operation_index "op index (Stellar) or log index (EVM)" + } +``` + +### Backward-compatibility strategy: additive columns, not a rename + +Renaming `stellar_tx_hash` → `tx_hash` (etc.) in place would break the currently-running old +binary mid-deploy — it still selects/binds the old column name. The two ways to avoid that are (a) +rename across two releases behind a compatibility view/generated column, or (b) keep every legacy +column and add generic ones alongside, backfilled from the legacy ones. This migration takes **(b)**: +`network`/`stellar_tx_hash`/`muxed_address` all still exist; `chain_id`/`tx_hash`/`deposit_address` +are new columns kept in lockstep by every `Store` write path (see +`octo_store::stellar_chain_id_for_network`). This was chosen over (a) because a view/generated-column +indirection adds a layer that complicates `SELECT *` / `RETURNING *` (what `sqlx::FromRow` relies on +throughout this crate) for comparatively little benefit at this schema's size, and because keeping +both names live is simpler to reason about during the transition than sequencing two coordinated +releases. The cost is some duplicated data (`tx_hash` == `stellar_tx_hash` for every Stellar row +today) — acceptable for a schema still under active expansion; a future cleanup migration can drop +the legacy columns once every caller has moved off `network`/`stellar_tx_hash`/`muxed_address` (that +work belongs to the `octo-chain` adapter rollout, #213/#215/#220/#223, not this migration). + +### Migration shape: additive → backfill → validate → enforce → swap indexes + +Every step is designed to avoid a long `ACCESS EXCLUSIVE` lock on `transactions` and to never let +the anti-double-credit invariant lapse: + +1. **Additive** (`0021`-`0022`): create `chains`, seeded with the two existing Stellar networks; + add every new column as nullable with no default (fast, metadata-only on PG11+). +2. **Backfill** (`0023`-`0025`): `wallets` (small) backfills in one `UPDATE`; `addresses` and + `transactions` backfill in batches of 5,000 rows, each batch committed independently (these + migration files are marked `-- no-transaction` specifically so a bare `COMMIT` inside the + backfill loop is legal) — no single transaction holds locks or accumulates WAL for the whole + table, and the loop resumes from the first still-`NULL` row if interrupted. +3. **Validate** (`0026`-`0028`): `chain_id`/`deposit_address` NOT NULL is added as `CHECK (...) + NOT VALID` (instant), validated in a separate migration/transaction under a lock that doesn't + block reads/writes (`VALIDATE CONSTRAINT`), then promoted to real `NOT NULL` — which, on + PG12+, reuses the now-validated CHECK to skip its own table scan. +4. **New indexes, built `CONCURRENTLY`** (`0029`-`0032`): including `uq_tx_onchain_chain` on + `(chain_id, tx_hash, operation_index)` — the re-scoped anti-double-credit guard — and + `uq_addresses_chain_deposit` on `(chain_id, deposit_address)` (generalizing the old + `UNIQUE(muxed_address)`, which is wrong for EVM: a deposit address is only unique *within* a + chain). +5. **Drop the legacy index** (`0033`), `DROP INDEX CONCURRENTLY`, only once `uq_tx_onchain_chain` + is confirmed built — so the anti-double-credit guarantee is never unenforced, even briefly: + the new chain-scoped index is live before the old global one goes away. + +**Caveat for real multi-replica deploys:** `CREATE INDEX CONCURRENTLY` and `VALIDATE CONSTRAINT` +each wait for every other in-flight transaction on the server to finish, including one that's +merely blocked acquiring sqlx's own migration advisory lock — so two processes calling +`store.migrate()` at the same moment while one of these migrations is pending can deadlock +(`octo_store::Store::migrate` serializes same-process callers against this, which is what a +parallel `cargo test` run exercises, but it cannot serialize across independent OS processes). +Apply this migration set once (`just migrate`) before rolling out new `bin/server` replicas, +rather than relying on every replica's own boot-time `store.migrate()` call to race it out.