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
1 change: 1 addition & 0 deletions migrations/0023_transaction_sequences.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
DROP TABLE IF EXISTS transaction_sequences;
9 changes: 9 additions & 0 deletions migrations/0023_transaction_sequences.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
CREATE TABLE IF NOT EXISTS transaction_sequences (
account_id TEXT PRIMARY KEY,
next_sequence NUMERIC(38, 0) NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX IF NOT EXISTS idx_transaction_sequences_updated_at
ON transaction_sequences (updated_at);
35 changes: 35 additions & 0 deletions src/services/postgresSequenceStore.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import assert from 'node:assert/strict';
import { PostgresSequenceStore } from './postgresSequenceStore.js';
import type { Queryable } from '../db.js';

describe('PostgresSequenceStore', () => {
test('uses an atomic upsert that advances from the greater of durable and ledger state', async () => {
const query = jest.fn().mockResolvedValue({ rows: [{ allocated_sequence: '43' }] });
const store = new PostgresSequenceStore({ query } as unknown as Queryable);

const allocated = await store.allocate('GACCOUNT', 42n);

assert.equal(allocated, 43n);
assert.match(query.mock.calls[0]?.[0], /ON CONFLICT \(account_id\) DO UPDATE/);
assert.match(query.mock.calls[0]?.[0], /GREATEST\(transaction_sequences\.next_sequence, \$2::numeric\) \+ 1/);
assert.deepEqual(query.mock.calls[0]?.[1], ['GACCOUNT', '42']);
});

test('surfaces partial database failures as retryable allocation failures to callers', async () => {
const store = new PostgresSequenceStore({
query: jest.fn().mockRejectedValue(new Error('database unavailable')),
} as unknown as Queryable);

await expect(store.allocate('GACCOUNT', 42n)).rejects.toThrow('database unavailable');
});

test('fails explicitly when the database write commits no returned sequence', async () => {
const store = new PostgresSequenceStore({
query: jest.fn().mockResolvedValue({ rows: [] }),
} as unknown as Queryable);

await expect(store.allocate('GACCOUNT', 42n)).rejects.toThrow(
'Sequence allocation did not return a reserved value'
);
});
});
32 changes: 32 additions & 0 deletions src/services/postgresSequenceStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { writeQuery, type Queryable } from '../db.js';
import type { SequenceStore } from './sequenceManager.js';

interface SequenceRow {
allocated_sequence: string;
}

export class PostgresSequenceStore implements SequenceStore {
constructor(private readonly db: Queryable = { query: writeQuery }) {}

async allocate(accountId: string, ledgerNextSequence: bigint): Promise<bigint> {
const { rows } = await this.db.query<SequenceRow>(
`
INSERT INTO transaction_sequences (account_id, next_sequence)
VALUES ($1, ($2::numeric + 1))
ON CONFLICT (account_id) DO UPDATE
SET
next_sequence = GREATEST(transaction_sequences.next_sequence, $2::numeric) + 1,
updated_at = NOW()
RETURNING (next_sequence - 1)::text AS allocated_sequence
`,
[accountId, ledgerNextSequence.toString()]
);

const allocatedSequence = rows[0]?.allocated_sequence;
if (!allocatedSequence) {
throw new Error('Sequence allocation did not return a reserved value');
}

return BigInt(allocatedSequence);
}
}
60 changes: 58 additions & 2 deletions src/services/sequenceManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,21 @@ export interface HorizonAccountLoader {
export interface SequenceManagerOptions {
/** Horizon loader used to fetch account objects. */
loader: HorizonAccountLoader;
/** Optional durable allocator used to make allocations survive restarts. */
store?: SequenceStore;
/** Timeout for the durable allocation step. Defaults to 5 seconds. */
allocationTimeoutMs?: number;
}

export interface SequenceStore {
allocate(accountId: string, ledgerNextSequence: bigint): Promise<bigint>;
}

export class SequenceAllocationError extends Error {
constructor(message: string) {
super(message);
this.name = 'SequenceAllocationError';
}
}

// ── SequenceManager ────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -88,9 +103,13 @@ export class SequenceManager {

/** Horizon loader injected at construction time. */
private readonly loader: HorizonAccountLoader;
private readonly store?: SequenceStore;
private readonly allocationTimeoutMs: number;

constructor(options: SequenceManagerOptions) {
this.loader = options.loader;
this.store = options.store;
this.allocationTimeoutMs = options.allocationTimeoutMs ?? 5_000;
}

/**
Expand Down Expand Up @@ -135,8 +154,14 @@ export class SequenceManager {
// Horizon returns sequence as a decimal string; parse to bigint for
// exact arithmetic (sequence numbers can exceed Number.MAX_SAFE_INTEGER
// on very active accounts).
const sequence = BigInt(account.sequence) + 1n;
return sequence;
const ledgerNextSequence = BigInt(account.sequence) + 1n;
if (!this.store) {
return ledgerNextSequence;
}

return await this.withAllocationTimeout(
this.store.allocate(accountId, ledgerNextSequence)
);
} finally {
// Always release the lock, even if loadAccount() threw.
resolveSlot();
Expand All @@ -161,4 +186,35 @@ export class SequenceManager {
hasLock(accountId: string): boolean {
return this.locks.has(accountId);
}

private async withAllocationTimeout(allocation: Promise<bigint>): Promise<bigint> {
let timeoutId: NodeJS.Timeout | undefined;
try {
return await Promise.race([
allocation,
new Promise<bigint>((_, reject) => {
timeoutId = setTimeout(() => {
reject(
new SequenceAllocationError(
'Timed out while reserving a durable Stellar sequence number'
)
);
}, this.allocationTimeoutMs);
}),
]);
} catch (error) {
if (error instanceof SequenceAllocationError) {
throw error;
}
throw new SequenceAllocationError(
`Failed to reserve a durable Stellar sequence number: ${
error instanceof Error && error.message.trim() ? error.message : 'Unknown error'
}`
);
} finally {
if (timeoutId) {
clearTimeout(timeoutId);
}
}
}
}
32 changes: 32 additions & 0 deletions src/services/transactionBuilder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,38 @@ describe('TransactionBuilderService', () => {
assert.equal(mockAddMemo.mock.calls.length, 0);
});

test('uses a durable reserved sequence when a sequence store is configured', async () => {
const sequenceStore = {
allocate: jest.fn().mockResolvedValue(9n),
};
const service = new TransactionBuilderService({ sequenceStore });

await service.buildDepositTransaction({
userPublicKey: 'GUSERPUBLICKEY123',
vaultContractId: 'CVAULTTEST',
amountUsdc: '12.3456789',
});

assert.deepEqual(sequenceStore.allocate.mock.calls[0], ['GUSERPUBLICKEY123', 2n]);
assert.equal(mockBuild.mock.calls[0]?.[0].sourceAccount.sequence, '8');
});

test('returns an explicit recoverable network error when durable sequence allocation fails', async () => {
const service = new TransactionBuilderService({
sequenceStore: {
allocate: jest.fn().mockRejectedValue(new Error('database unavailable')),
},
});

await expect(
service.buildDepositTransaction({
userPublicKey: 'GUSERPUBLICKEY123',
vaultContractId: 'CVAULTTEST',
amountUsdc: '12.3456789',
})
).rejects.toThrow(NetworkError);
});

test('uses the correct network passphrase from configuration', async () => {
const service = new TransactionBuilderService();

Expand Down
52 changes: 52 additions & 0 deletions src/services/transactionBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import {
extractSimulationDetails,
type SimulationDetails,
} from '../lib/simulationDiagnostics.js';
import { PostgresSequenceStore } from './postgresSequenceStore.js';
import { SequenceAllocationError, type SequenceStore } from './sequenceManager.js';

export type StellarNetwork = 'testnet' | 'mainnet';

Expand Down Expand Up @@ -137,6 +139,8 @@ interface NormalizedMemo {

export interface TransactionBuilderServiceOptions {
createServer?: (horizonUrl: string) => HorizonAccountLoader;
sequenceStore?: SequenceStore | false;
sequenceAllocationTimeoutMs?: number;
baseFee?: string | number;
timeoutSeconds?: number;
/** Maximum number of retries for transient Horizon errors. Default: 3. */
Expand Down Expand Up @@ -210,6 +214,21 @@ export class TransactionBuilderService {
shouldRetry: isHorizonTransientError,
}
);
const sequenceStore =
this.options.sequenceStore === undefined
? process.env.NODE_ENV === 'test'
? undefined
: new PostgresSequenceStore()
: this.options.sequenceStore || undefined;

if (sequenceStore) {
const accountSequence = (sourceAccount as { sequence?: unknown }).sequence;
const ledgerNextSequence = BigInt(String(accountSequence)) + 1n;
const reservedSequence = await this.withSequenceAllocationTimeout(
sequenceStore.allocate(sourceKey, ledgerNextSequence)
);
(sourceAccount as { sequence: string }).sequence = (reservedSequence - 1n).toString();
}
} catch (error) {
throw this.mapLoadAccountError(sourceKey, error);
}
Expand Down Expand Up @@ -408,6 +427,10 @@ export class TransactionBuilderService {
}

private mapLoadAccountError(accountId: string, error: unknown): Error {
if (error instanceof SequenceAllocationError) {
return new NetworkError(error.message);
}

const message = this.getErrorMessage(error).toLowerCase();

if (
Expand All @@ -434,4 +457,33 @@ export class TransactionBuilderService {

return 'Unknown error';
}

private async withSequenceAllocationTimeout(allocation: Promise<bigint>): Promise<bigint> {
let timeoutId: NodeJS.Timeout | undefined;
try {
return await Promise.race([
allocation,
new Promise<bigint>((_, reject) => {
timeoutId = setTimeout(() => {
reject(
new SequenceAllocationError(
'Timed out while reserving a durable Stellar sequence number'
)
);
}, this.options.sequenceAllocationTimeoutMs ?? 5_000);
}),
]);
} catch (error) {
if (error instanceof SequenceAllocationError) {
throw error;
}
throw new SequenceAllocationError(
`Failed to reserve a durable Stellar sequence number: ${this.getErrorMessage(error)}`
);
} finally {
if (timeoutId) {
clearTimeout(timeoutId);
}
}
}
}
Loading