From 52827e68948d810d4c4c7a109cbc923ccacc77bd Mon Sep 17 00:00:00 2001 From: kwotor Date: Thu, 27 Aug 2026 00:53:23 +0000 Subject: [PATCH] Harden settlement XDR intent validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bind optional persisted source sequences and explicit fee ceilings into the shared signed-envelope validator so wallet-signed payments cannot alter replay sequencing or fee exposure. Closes #190 Generated with Codebuff 🤖 Co-Authored-By: Codebuff --- src/services/settlement-xdr.ts | 3 +++ src/services/stellar.ts | 11 ++++++++++- tests/settlement-xdr-validation.test.ts | 11 ++++++++++- 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/services/settlement-xdr.ts b/src/services/settlement-xdr.ts index 3698e2f..6f9102a 100644 --- a/src/services/settlement-xdr.ts +++ b/src/services/settlement-xdr.ts @@ -32,6 +32,8 @@ export interface SettlementIntentRecord { assetIssuer: string | null; /** Server-controlled signing deadline; null on rows predating expiry tracking. */ expiresAt?: Date | null; + /** Sequence used to build the persisted unsigned payment intent, when stored. */ + sourceSequence?: string; from: { stellarPublicKey: string }; to: { stellarPublicKey: string }; } @@ -51,6 +53,7 @@ export function settlementPaymentIntent( asset: { code: settlement.assetCode, issuer: settlement.assetIssuer }, amount: String(settlement.amount), memoCode: settlement.shortCode, + sourceSequence: settlement.sourceSequence, expiresAt: settlement.expiresAt ?? null, resource: "settlement", }; diff --git a/src/services/stellar.ts b/src/services/stellar.ts index 11c89d4..f97f265 100644 --- a/src/services/stellar.ts +++ b/src/services/stellar.ts @@ -439,6 +439,10 @@ export interface PaymentExpectation { asset: AssetSpec; amount: string; memoCode: string; + /** Sequence used when the server created the unsigned intent. */ + sourceSequence?: string; + /** Maximum fee per operation accepted for this intent. */ + maxFeeStroops?: number; /** Recorded intent expiry; when present, the envelope's bounds must agree. */ expiresAt?: Date | null; /** Names the resource in the expiration error, e.g. "settlement". */ @@ -501,15 +505,20 @@ function assertMatchesIntent(tx: Transaction, expected: PaymentExpectation): voi throw Errors.badRequest("xdr_mismatch", "Transaction source does not match"); } + if (expected.sourceSequence !== undefined && tx.sequence.toString() !== expected.sourceSequence) { + throw Errors.badRequest("xdr_mismatch", "Transaction sequence does not match"); + } + if (tx.operations.length !== 1) { throw Errors.badRequest("xdr_mismatch", "Expected exactly one operation"); } const fee = Number(tx.fee); + const maxFee = expected.maxFeeStroops ?? MAX_FEE_STROOPS_PER_OP * tx.operations.length; if ( !Number.isFinite(fee) || fee < MIN_FEE_STROOPS_PER_OP * tx.operations.length || - fee > MAX_FEE_STROOPS_PER_OP * tx.operations.length + fee > maxFee ) { throw Errors.badRequest( "xdr_mismatch", diff --git a/tests/settlement-xdr-validation.test.ts b/tests/settlement-xdr-validation.test.ts index 96f3e0c..ffad6ac 100644 --- a/tests/settlement-xdr-validation.test.ts +++ b/tests/settlement-xdr-validation.test.ts @@ -72,15 +72,17 @@ function buildCustomXdr({ extraOperation = false, memo = "MP:ABC123", timeoutSeconds = 300, + sequence = "12345", }: { fee?: string; operationSource?: string; extraOperation?: boolean; memo?: string; timeoutSeconds?: number; + sequence?: string; } = {}): string { const txb = new TransactionBuilder( - new Account(settlement.from.stellarPublicKey, "12345"), + new Account(settlement.from.stellarPublicKey, sequence), { fee, networkPassphrase: config.networkPassphrase } ).addOperation( Operation.payment({ @@ -290,6 +292,13 @@ describe("validateSettlementXdr", () => { expect(() => validateSettlementXdr(signedXdr, settlement)).toThrow(/fee/i); }); + it("rejects an envelope with a different source sequence when the intent records one", () => { + const intentWithSequence = { ...settlement, sourceSequence: "12345" }; + const signedXdr = sign(buildCustomXdr({ sequence: "12346" })); + + expect(() => validateSettlementXdr(signedXdr, intentWithSequence)).toThrow(/sequence/i); + }); + it("rejects an envelope signed for a different network", () => { const wrongNetwork = "Wrong SDF Network ; September 2015"; const tx = new Transaction(buildXdr(), wrongNetwork);