Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/services/settlement-xdr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}
Expand All @@ -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",
};
Expand Down
11 changes: 10 additions & 1 deletion src/services/stellar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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". */
Expand Down Expand Up @@ -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",
Expand Down
11 changes: 10 additions & 1 deletion tests/settlement-xdr-validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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);
Expand Down
Loading