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 e93ce74..5b4897b 100644 --- a/src/services/stellar.ts +++ b/src/services/stellar.ts @@ -610,6 +610,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". */ @@ -672,15 +676,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);