From f67c96a64260be78fb07cfe205769df57e4883b9 Mon Sep 17 00:00:00 2001 From: muokwejosh-cloud Date: Tue, 18 Aug 2026 06:44:10 +0100 Subject: [PATCH 1/3] Preserve financial idempotency receipts --- contracts/chainmove-pool/src/lib.rs | 227 +++++++++++++++++++----- docs/financial-idempotency-retention.md | 27 +++ 2 files changed, 207 insertions(+), 47 deletions(-) create mode 100644 docs/financial-idempotency-retention.md diff --git a/contracts/chainmove-pool/src/lib.rs b/contracts/chainmove-pool/src/lib.rs index fa9e705d..61a67b00 100644 --- a/contracts/chainmove-pool/src/lib.rs +++ b/contracts/chainmove-pool/src/lib.rs @@ -70,7 +70,7 @@ pub struct TransitionEvent { #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] -enum OperationKind { +pub enum OperationKind { Funding, Repayment, Refund, @@ -78,11 +78,26 @@ enum OperationKind { #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] -struct OperationReceipt { - kind: OperationKind, - pool_id: u64, - participant: Address, - amount: i128, +pub struct OperationReceipt { + pub kind: OperationKind, + pub pool_id: u64, + pub participant: Address, + pub amount: i128, + /// The result from the original operation. Retries must not return a + /// position that has since changed due to a repayment or refund. + pub result: InvestorPosition, + /// Ledger at which an operator must have archived this marker. + pub archive_required_at_ledger: u32, + /// The end of the financial retention policy. A restored receipt may not + /// be accepted after this ledger, even if an archive still contains it. + pub financial_retention_ends_at_ledger: u32, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ReferenceRetentionStatus { + pub receipt: OperationReceipt, + pub archive_required: bool, } #[contracttype] @@ -97,6 +112,12 @@ enum DataKey { const DAY_IN_LEDGERS: u32 = 17280; const RENT_THRESHOLD: u32 = 7 * DAY_IN_LEDGERS; const RENT_EXTEND_TO: u32 = 30 * DAY_IN_LEDGERS; +// Soroban persistent entries have a bounded rent window. Keep the online +// replay marker for six months and require the immutable event/archive path to +// retain it for the seven-year financial retention period. +const REPLAY_MARKER_THRESHOLD: u32 = 150 * DAY_IN_LEDGERS; +const REPLAY_MARKER_EXTEND_TO: u32 = 180 * DAY_IN_LEDGERS; +const FINANCIAL_RETENTION_LEDGERS: u32 = 7 * 365 * DAY_IN_LEDGERS; #[contractimpl] impl ChainMovePoolContract { @@ -111,7 +132,9 @@ impl ChainMovePoolContract { target_amount: i128, ) -> Result { owner.require_auth(); - env.storage().instance().extend_ttl(RENT_THRESHOLD, RENT_EXTEND_TO); + env.storage() + .instance() + .extend_ttl(RENT_THRESHOLD, RENT_EXTEND_TO); if pool_id == 0 || total_units == 0 || target_amount <= 0 || asset_label.is_empty() { return Err(ContractError::InvalidInput); @@ -137,7 +160,9 @@ impl ChainMovePoolContract { }; env.storage().persistent().set(&key, &pool); - env.storage().persistent().extend_ttl(&key, RENT_THRESHOLD, RENT_EXTEND_TO); + env.storage() + .persistent() + .extend_ttl(&key, RENT_THRESHOLD, RENT_EXTEND_TO); publish_transition( &env, @@ -165,7 +190,9 @@ impl ChainMovePoolContract { reference: String, ) -> Result { investor.require_auth(); - env.storage().instance().extend_ttl(RENT_THRESHOLD, RENT_EXTEND_TO); + env.storage() + .instance() + .extend_ttl(RENT_THRESHOLD, RENT_EXTEND_TO); if pool_id == 0 || amount <= 0 || reference.is_empty() { return Err(ContractError::InvalidInput); @@ -188,7 +215,9 @@ impl ChainMovePoolContract { .persistent() .get(&pool_key) .ok_or(ContractError::PoolNotFound)?; - env.storage().persistent().extend_ttl(&pool_key, RENT_THRESHOLD, RENT_EXTEND_TO); + env.storage() + .persistent() + .extend_ttl(&pool_key, RENT_THRESHOLD, RENT_EXTEND_TO); if !pool.active { return Err(ContractError::PoolInactive); @@ -214,7 +243,9 @@ impl ChainMovePoolContract { pool.total_invested = new_total; pool.funded_units = new_units; env.storage().persistent().set(&pool_key, &pool); - env.storage().persistent().extend_ttl(&pool_key, RENT_THRESHOLD, RENT_EXTEND_TO); + env.storage() + .persistent() + .extend_ttl(&pool_key, RENT_THRESHOLD, RENT_EXTEND_TO); let position_key = DataKey::InvestorPosition(pool_id, investor.clone()); let mut position = @@ -233,7 +264,9 @@ impl ChainMovePoolContract { position.invested = checked_add_i128(position.invested, amount)?; position.units = checked_add_u64(position.units, units)?; env.storage().persistent().set(&position_key, &position); - env.storage().persistent().extend_ttl(&position_key, RENT_THRESHOLD, RENT_EXTEND_TO); + env.storage() + .persistent() + .extend_ttl(&position_key, RENT_THRESHOLD, RENT_EXTEND_TO); write_reference( &env, @@ -242,6 +275,7 @@ impl ChainMovePoolContract { pool_id, investor.clone(), amount, + position.clone(), ); publish_transition( &env, @@ -270,7 +304,9 @@ impl ChainMovePoolContract { reference: String, ) -> Result { payer.require_auth(); - env.storage().instance().extend_ttl(RENT_THRESHOLD, RENT_EXTEND_TO); + env.storage() + .instance() + .extend_ttl(RENT_THRESHOLD, RENT_EXTEND_TO); if pool_id == 0 || amount <= 0 || reference.is_empty() { return Err(ContractError::InvalidInput); @@ -293,7 +329,9 @@ impl ChainMovePoolContract { .persistent() .get(&pool_key) .ok_or(ContractError::PoolNotFound)?; - env.storage().persistent().extend_ttl(&pool_key, RENT_THRESHOLD, RENT_EXTEND_TO); + env.storage() + .persistent() + .extend_ttl(&pool_key, RENT_THRESHOLD, RENT_EXTEND_TO); if pool.asset != asset { return Err(ContractError::WrongAsset); @@ -309,7 +347,9 @@ impl ChainMovePoolContract { .persistent() .get(&position_key) .ok_or(ContractError::InvestorPositionNotFound)?; - env.storage().persistent().extend_ttl(&position_key, RENT_THRESHOLD, RENT_EXTEND_TO); + env.storage() + .persistent() + .extend_ttl(&position_key, RENT_THRESHOLD, RENT_EXTEND_TO); let outstanding = position .invested @@ -325,9 +365,13 @@ impl ChainMovePoolContract { position.repaid = checked_add_i128(position.repaid, amount)?; env.storage().persistent().set(&pool_key, &pool); - env.storage().persistent().extend_ttl(&pool_key, RENT_THRESHOLD, RENT_EXTEND_TO); + env.storage() + .persistent() + .extend_ttl(&pool_key, RENT_THRESHOLD, RENT_EXTEND_TO); env.storage().persistent().set(&position_key, &position); - env.storage().persistent().extend_ttl(&position_key, RENT_THRESHOLD, RENT_EXTEND_TO); + env.storage() + .persistent() + .extend_ttl(&position_key, RENT_THRESHOLD, RENT_EXTEND_TO); write_reference( &env, @@ -336,6 +380,7 @@ impl ChainMovePoolContract { pool_id, investor.clone(), amount, + position.clone(), ); publish_transition( &env, @@ -363,7 +408,9 @@ impl ChainMovePoolContract { reference: String, ) -> Result { owner.require_auth(); - env.storage().instance().extend_ttl(RENT_THRESHOLD, RENT_EXTEND_TO); + env.storage() + .instance() + .extend_ttl(RENT_THRESHOLD, RENT_EXTEND_TO); if pool_id == 0 || amount <= 0 || reference.is_empty() { return Err(ContractError::InvalidInput); @@ -386,7 +433,9 @@ impl ChainMovePoolContract { .persistent() .get(&pool_key) .ok_or(ContractError::PoolNotFound)?; - env.storage().persistent().extend_ttl(&pool_key, RENT_THRESHOLD, RENT_EXTEND_TO); + env.storage() + .persistent() + .extend_ttl(&pool_key, RENT_THRESHOLD, RENT_EXTEND_TO); if pool.owner != owner { return Err(ContractError::InvalidInput); @@ -398,7 +447,9 @@ impl ChainMovePoolContract { .persistent() .get(&position_key) .ok_or(ContractError::InvestorPositionNotFound)?; - env.storage().persistent().extend_ttl(&position_key, RENT_THRESHOLD, RENT_EXTEND_TO); + env.storage() + .persistent() + .extend_ttl(&position_key, RENT_THRESHOLD, RENT_EXTEND_TO); if amount > position.invested { return Err(ContractError::NothingToRefund); @@ -412,11 +463,15 @@ impl ChainMovePoolContract { position.units = checked_sub_u64(position.units, refund_units)?; pool.total_invested = checked_sub_i128(pool.total_invested, amount)?; pool.funded_units = checked_sub_u64(pool.funded_units, refund_units)?; - + env.storage().persistent().set(&pool_key, &pool); - env.storage().persistent().extend_ttl(&pool_key, RENT_THRESHOLD, RENT_EXTEND_TO); + env.storage() + .persistent() + .extend_ttl(&pool_key, RENT_THRESHOLD, RENT_EXTEND_TO); env.storage().persistent().set(&position_key, &position); - env.storage().persistent().extend_ttl(&position_key, RENT_THRESHOLD, RENT_EXTEND_TO); + env.storage() + .persistent() + .extend_ttl(&position_key, RENT_THRESHOLD, RENT_EXTEND_TO); write_reference( &env, @@ -425,6 +480,7 @@ impl ChainMovePoolContract { pool_id, investor.clone(), amount, + position.clone(), ); publish_transition( &env, @@ -446,7 +502,9 @@ impl ChainMovePoolContract { /// Marks a pool inactive so no further funding is accepted. pub fn close_pool(env: Env, owner: Address, pool_id: u64) -> Result { owner.require_auth(); - env.storage().instance().extend_ttl(RENT_THRESHOLD, RENT_EXTEND_TO); + env.storage() + .instance() + .extend_ttl(RENT_THRESHOLD, RENT_EXTEND_TO); if pool_id == 0 { return Err(ContractError::InvalidInput); @@ -465,7 +523,9 @@ impl ChainMovePoolContract { pool.active = false; env.storage().persistent().set(&key, &pool); - env.storage().persistent().extend_ttl(&key, RENT_THRESHOLD, RENT_EXTEND_TO); + env.storage() + .persistent() + .extend_ttl(&key, RENT_THRESHOLD, RENT_EXTEND_TO); publish_transition( &env, @@ -509,7 +569,9 @@ impl ChainMovePoolContract { .get(&key) .ok_or(ContractError::PoolNotFound)?; - env.storage().persistent().extend_ttl(&key, RENT_THRESHOLD, RENT_EXTEND_TO); + env.storage() + .persistent() + .extend_ttl(&key, RENT_THRESHOLD, RENT_EXTEND_TO); Ok(pool) } @@ -529,7 +591,9 @@ impl ChainMovePoolContract { .get(&key) .ok_or(ContractError::InvestorPositionNotFound)?; - env.storage().persistent().extend_ttl(&key, RENT_THRESHOLD, RENT_EXTEND_TO); + env.storage() + .persistent() + .extend_ttl(&key, RENT_THRESHOLD, RENT_EXTEND_TO); Ok(position) } @@ -549,7 +613,9 @@ impl ChainMovePoolContract { .persistent() .get(&pool_key) .ok_or(ContractError::PoolNotFound)?; - env.storage().persistent().extend_ttl(&pool_key, RENT_THRESHOLD, RENT_EXTEND_TO); + env.storage() + .persistent() + .extend_ttl(&pool_key, RENT_THRESHOLD, RENT_EXTEND_TO); let position_key = DataKey::InvestorPosition(pool_id, investor); let position: InvestorPosition = env @@ -557,7 +623,9 @@ impl ChainMovePoolContract { .persistent() .get(&position_key) .ok_or(ContractError::InvestorPositionNotFound)?; - env.storage().persistent().extend_ttl(&position_key, RENT_THRESHOLD, RENT_EXTEND_TO); + env.storage() + .persistent() + .extend_ttl(&position_key, RENT_THRESHOLD, RENT_EXTEND_TO); let numerator = position .invested @@ -565,6 +633,59 @@ impl ChainMovePoolContract { .ok_or(ContractError::ArithmeticOverflow)?; Ok((numerator / pool.target_amount) as u64) } + + /// Gives operators a deterministic archive deadline before the bounded + /// on-chain replay marker can expire. Archive the emitted receipt event and + /// use `restore_reference` before a late retry reaches the contract. + pub fn reference_retention_status( + env: Env, + reference: String, + ) -> Option { + let key = DataKey::Reference(reference); + let receipt = env + .storage() + .persistent() + .get::(&key)?; + let now = env.ledger().sequence(); + Some(ReferenceRetentionStatus { + archive_required: now >= receipt.archive_required_at_ledger, + receipt, + }) + } + + /// Restores a compact archived receipt for a late retry. Only the pool + /// owner can perform this recovery, and never beyond financial retention. + pub fn restore_reference( + env: Env, + owner: Address, + reference: String, + receipt: OperationReceipt, + ) -> Result<(), ContractError> { + owner.require_auth(); + if env.ledger().sequence() > receipt.financial_retention_ends_at_ledger { + return Err(ContractError::InvalidInput); + } + let pool_key = DataKey::Pool(receipt.pool_id); + let pool: Pool = env + .storage() + .persistent() + .get(&pool_key) + .ok_or(ContractError::PoolNotFound)?; + if pool.owner != owner { + return Err(ContractError::InvalidInput); + } + let key = DataKey::Reference(reference); + if env.storage().persistent().has(&key) { + return Err(ContractError::DuplicateReference); + } + env.storage().persistent().set(&key, &receipt); + env.storage().persistent().extend_ttl( + &key, + REPLAY_MARKER_THRESHOLD, + REPLAY_MARKER_EXTEND_TO, + ); + Ok(()) + } } fn read_idempotent_position( @@ -581,7 +702,11 @@ fn read_idempotent_position( .persistent() .get::(&key) { - env.storage().persistent().extend_ttl(&key, RENT_THRESHOLD, RENT_EXTEND_TO); + env.storage().persistent().extend_ttl( + &key, + REPLAY_MARKER_THRESHOLD, + REPLAY_MARKER_EXTEND_TO, + ); if receipt.kind != kind || receipt.pool_id != pool_id || receipt.participant != participant @@ -590,14 +715,7 @@ fn read_idempotent_position( return Err(ContractError::DuplicateReference); } - let pos_key = DataKey::InvestorPosition(pool_id, participant); - let position = env - .storage() - .persistent() - .get(&pos_key) - .ok_or(ContractError::InvestorPositionNotFound)?; - env.storage().persistent().extend_ttl(&pos_key, RENT_THRESHOLD, RENT_EXTEND_TO); - return Ok(Some(position)); + return Ok(Some(receipt.result)); } Ok(None) @@ -610,18 +728,33 @@ fn write_reference( pool_id: u64, participant: Address, amount: i128, + result: InvestorPosition, ) { let key = DataKey::Reference(reference); - env.storage().persistent().set( - &key, - &OperationReceipt { - kind, - pool_id, - participant, - amount, - }, + let current_ledger = env.ledger().sequence(); + let receipt = OperationReceipt { + kind, + pool_id, + participant, + amount, + result, + archive_required_at_ledger: current_ledger.saturating_add(REPLAY_MARKER_THRESHOLD), + financial_retention_ends_at_ledger: current_ledger + .saturating_add(FINANCIAL_RETENTION_LEDGERS), + }; + env.storage().persistent().set(&key, &receipt); + env.storage() + .persistent() + .extend_ttl(&key, REPLAY_MARKER_THRESHOLD, REPLAY_MARKER_EXTEND_TO); + // This immutable event is the archival hand-off for replay protection once + // the bounded persistent marker reaches its rent limit. + env.events().publish( + ( + Symbol::new(env, "chainmove_pool_v1"), + Symbol::new(env, "reference_receipt_v1"), + ), + receipt, ); - env.storage().persistent().extend_ttl(&key, RENT_THRESHOLD, RENT_EXTEND_TO); } fn allocate_units(pool: &Pool, amount: i128, new_total: i128) -> Result { diff --git a/docs/financial-idempotency-retention.md b/docs/financial-idempotency-retention.md new file mode 100644 index 00000000..685f470e --- /dev/null +++ b/docs/financial-idempotency-retention.md @@ -0,0 +1,27 @@ +# Financial idempotency retention + +Funding, repayment, and refund references are financial operation receipts. A +reference is never treated as reusable merely because its on-chain storage entry +has expired. + +## Policy + +- The on-chain replay marker is retained for 180 days, with archive action due + after 150 days. This bounds Soroban rent exposure. +- Each receipt preserves the original operation result, so a retry returns the + original result rather than the current, subsequently changed position. +- Receipt events must be archived by the operator archive pipeline for seven + years from their creation ledger. The archive is the authoritative source for + late replay protection after the bounded on-chain marker expires. +- Before processing a late retry, an operator restores the archived compact + receipt with `restore_reference`. Restoration is limited to the pool owner + and is rejected after the seven-year financial retention deadline. + +## Operator procedure + +1. Poll `reference_retention_status` for references approaching the archive + deadline; `archive_required` is the actionable signal. +2. Verify the immutable receipt event is present in the financial archive. +3. For a late retry whose marker is absent, restore the archived receipt before + retrying the financial operation. The retry returns its original result and + performs no transfer. From 27ef8da04386f6a50dd8c01a939701b2b35e6b0d Mon Sep 17 00:00:00 2001 From: muokwejosh-cloud Date: Tue, 18 Aug 2026 06:45:47 +0100 Subject: [PATCH 2/3] Format pool contract test --- contracts/chainmove-pool/src/test.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/contracts/chainmove-pool/src/test.rs b/contracts/chainmove-pool/src/test.rs index 2da910aa..fed85534 100644 --- a/contracts/chainmove-pool/src/test.rs +++ b/contracts/chainmove-pool/src/test.rs @@ -489,4 +489,3 @@ fn test_ttl_and_legacy_key_migration() { assert!(ttl > 0); }); } - From 217b07d50ebcf6a6f3eda67978862afa74ca6ee0 Mon Sep 17 00:00:00 2001 From: muokwejosh-cloud Date: Tue, 18 Aug 2026 06:56:34 +0100 Subject: [PATCH 3/3] Fix TypeScript CI errors --- __tests__/api/stellar/activity.test.ts | 8 ++++---- __tests__/api/stellar/sync.test.ts | 4 ++-- __tests__/auth-hardening.test.ts | 10 +++++----- app/api/recovery/[id]/execute/route.ts | 2 +- components/ui/alert.tsx | 6 ++++-- lib/settlement/settlement-service.ts | 5 ++++- 6 files changed, 20 insertions(+), 15 deletions(-) diff --git a/__tests__/api/stellar/activity.test.ts b/__tests__/api/stellar/activity.test.ts index 4fd89504..2c3980a0 100644 --- a/__tests__/api/stellar/activity.test.ts +++ b/__tests__/api/stellar/activity.test.ts @@ -59,7 +59,7 @@ describe("GET /api/stellar/activity", () => { response: NextResponse.json({ message: "Unauthorized" }, { status: 401 }), }) - const response = await GET(buildRequest()) + const response = (await GET(buildRequest()))! expect(response.status).toBe(401) }) @@ -75,7 +75,7 @@ describe("GET /api/stellar/activity", () => { issuerPublicKey: "GD123", }) - const response = await GET(buildRequest()) + const response = (await GET(buildRequest()))! const payload = await response.json() expect(response.status).toBe(200) @@ -93,7 +93,7 @@ describe("GET /api/stellar/activity", () => { network: "testnet", }) - const response = await GET(buildRequest()) + const response = (await GET(buildRequest()))! const payload = await response.json() expect(response.status).toBe(200) @@ -136,7 +136,7 @@ describe("GET /api/stellar/activity", () => { }), }) - const response = await GET(buildRequest()) + const response = (await GET(buildRequest()))! const payload = await response.json() expect(response.status).toBe(200) diff --git a/__tests__/api/stellar/sync.test.ts b/__tests__/api/stellar/sync.test.ts index 94f2fd99..70bbbc11 100644 --- a/__tests__/api/stellar/sync.test.ts +++ b/__tests__/api/stellar/sync.test.ts @@ -45,7 +45,7 @@ describe("POST /api/admin/stellar/sync", () => { response: NextResponse.json({ message: "Unauthorized" }, { status: 401 }), }) - const response = await POST(buildRequest()) + const response = (await POST(buildRequest()))! expect(response.status).toBe(401) expect(sync).not.toHaveBeenCalled() }) @@ -60,7 +60,7 @@ describe("POST /api/admin/stellar/sync", () => { lastCursor: "cursor-123", }) - const response = await POST(buildRequest()) + const response = (await POST(buildRequest()))! const payload = await response.json() expect(response.status).toBe(200) diff --git a/__tests__/auth-hardening.test.ts b/__tests__/auth-hardening.test.ts index 85b713b8..f8601099 100644 --- a/__tests__/auth-hardening.test.ts +++ b/__tests__/auth-hardening.test.ts @@ -149,7 +149,7 @@ describe("requireRecentAuth", () => { // ── Session revocation ──────────────────────────────────────────────────────── -vi.mock("../models/RevokedSession", () => ({ default: { create: vi.fn().mockResolvedValue({}), findOne: vi.fn() } }), { virtual: true }) +vi.mock("../models/RevokedSession", () => ({ default: { create: vi.fn().mockResolvedValue({}), findOne: vi.fn() } })) // Mongoose mock so the schema registration doesn't fail in unit test context. vi.mock("mongoose", async () => { @@ -276,7 +276,7 @@ describe("POST /api/auth/stellar/link — recent-auth enforcement", () => { getAuthenticatedUser.mockResolvedValue({ user: makeUser(), shouldRefreshSession: false }) mockExtractPrivyToken.mockReturnValue(null) - const response = await POST(buildRequest({ stellarPublicKey: VALID_STELLAR_KEY })) + const response = (await POST(buildRequest({ stellarPublicKey: VALID_STELLAR_KEY })))! expect(response.status).toBe(401) const body = await response.json() expect(body.code).toBe("RECENT_AUTH_REQUIRED") @@ -287,7 +287,7 @@ describe("POST /api/auth/stellar/link — recent-auth enforcement", () => { mockExtractPrivyToken.mockReturnValue("bad-token") mockVerifyPrivyToken.mockRejectedValue(new Error("JWTExpired")) - const response = await POST(buildRequest({ stellarPublicKey: VALID_STELLAR_KEY })) + const response = (await POST(buildRequest({ stellarPublicKey: VALID_STELLAR_KEY })))! expect(response.status).toBe(401) }) @@ -300,7 +300,7 @@ describe("POST /api/auth/stellar/link — recent-auth enforcement", () => { iat: Math.floor(Date.now() / 1_000) - 30, }) - const response = await POST(buildRequest({ stellarPublicKey: VALID_STELLAR_KEY })) + const response = (await POST(buildRequest({ stellarPublicKey: VALID_STELLAR_KEY })))! expect(response.status).toBe(401) expect(mockLogAuthEvent).toHaveBeenCalledWith(expect.objectContaining({ type: "privy_subject_mismatch" })) }) @@ -314,7 +314,7 @@ describe("POST /api/auth/stellar/link — recent-auth enforcement", () => { iat: Math.floor(Date.now() / 1_000) - (RECENT_AUTH_CRITICAL_MAX_AGE_SECONDS + 30), }) - const response = await POST(buildRequest({ stellarPublicKey: VALID_STELLAR_KEY })) + const response = (await POST(buildRequest({ stellarPublicKey: VALID_STELLAR_KEY })))! expect(response.status).toBe(401) expect(mockLogAuthEvent).toHaveBeenCalledWith( expect.objectContaining({ type: "high_risk_action_denied_recent_auth" }), diff --git a/app/api/recovery/[id]/execute/route.ts b/app/api/recovery/[id]/execute/route.ts index a0d7b205..958b6bf1 100644 --- a/app/api/recovery/[id]/execute/route.ts +++ b/app/api/recovery/[id]/execute/route.ts @@ -15,7 +15,7 @@ import WalletRecovery from "@/models/WalletRecovery" import WalletMigrationRecord from "@/models/WalletMigrationRecord" import User from "@/models/User" import { getAuthenticatedUser } from "@/lib/auth/current-user" -import { assertTransition, isTerminal, allFactorsVerified as _allFactorsVerified } from "@/lib/recovery/recovery-state-machine" +import { assertTransition, isTerminal } from "@/lib/recovery/recovery-state-machine" import { allFactorsVerified } from "@/lib/recovery/recovery-factors" import { sendRecoveryNotifications } from "@/lib/recovery/recovery-notifications" diff --git a/components/ui/alert.tsx b/components/ui/alert.tsx index 57996ebc..6eb02d7a 100644 --- a/components/ui/alert.tsx +++ b/components/ui/alert.tsx @@ -9,8 +9,10 @@ const alertVariants = cva( variants: { variant: { default: "bg-background text-foreground", - destructive: - "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + destructive: + "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + warning: + "border-amber-500/50 text-amber-700 dark:text-amber-400 [&>svg]:text-amber-600 dark:[&>svg]:text-amber-400", }, }, defaultVariants: { diff --git a/lib/settlement/settlement-service.ts b/lib/settlement/settlement-service.ts index 508b2889..542040c8 100644 --- a/lib/settlement/settlement-service.ts +++ b/lib/settlement/settlement-service.ts @@ -361,7 +361,10 @@ export async function evaluateFinalityTimeouts(): Promise<{ let expiredCount = 0 for (const s of activeSettlements) { - const config = getRailSettlementConfig(s.rail, s.environment) + const config = getRailSettlementConfig( + s.rail, + s.environment as "development" | "production" | "test" | undefined, + ) const ageMs = now.getTime() - new Date(s.createdAt).getTime() const thresholdMs = s.currentState === "observed" || s.currentState === "provisionally_credited"