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
21 changes: 19 additions & 2 deletions src/soroban.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,21 @@ export const NETWORK_PASSPHRASE: Record<Network, string> = {
local: Networks.STANDALONE,
};

export interface ConfirmationPollingOptions {
pollIntervalMs?: number;
maxAttempts?: number;
}

export const DEFAULT_CONFIRMATION_POLL_INTERVAL_MS = 1000;
export const DEFAULT_CONFIRMATION_MAX_ATTEMPTS = 30;

function normalizePollingOptions(options: ConfirmationPollingOptions = {}): Required<ConfirmationPollingOptions> {
return {
pollIntervalMs: options.pollIntervalMs ?? DEFAULT_CONFIRMATION_POLL_INTERVAL_MS,
maxAttempts: options.maxAttempts ?? DEFAULT_CONFIRMATION_MAX_ATTEMPTS,
};
}

/**
* Build a contract-call transaction for simulate or submit.
*
Expand Down Expand Up @@ -72,8 +87,10 @@ export async function invokeContract(
passphrase: string,
signer: Signer,
tx: ReturnType<TransactionBuilder['build']>,
pollingOptions: ConfirmationPollingOptions = {},
): Promise<string> {
const server = new SorobanRpc.Server(rpcUrl, { allowHttp: rpcUrl.startsWith('http://') });
const polling = normalizePollingOptions(pollingOptions);

// Simulate
let simResult;
Expand Down Expand Up @@ -105,8 +122,8 @@ export async function invokeContract(

// Poll for confirmation
const hash = sent.hash;
for (let i = 0; i < 30; i++) {
await sleep(1000);
for (let i = 0; i < polling.maxAttempts; i++) {
await sleep(polling.pollIntervalMs);
let status;
try {
status = await server.getTransaction(hash);
Expand Down
8 changes: 6 additions & 2 deletions src/streams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ import {
getTokenDecimals,
DEFAULT_RPC,
NETWORK_PASSPHRASE,
DEFAULT_CONFIRMATION_MAX_ATTEMPTS,
DEFAULT_CONFIRMATION_POLL_INTERVAL_MS,
} from './soroban.js';
import { FactoryModule } from './factory.js';
import { ConduitError, RateLimitError, StreamErrorCode } from './errors.js';
Expand Down Expand Up @@ -512,8 +514,10 @@ export class StreamsModule {
throw new Error(`Transaction rejected: ${JSON.stringify(sent.errorResult)}`);
}
const hash = sent.hash;
for (let i = 0; i < 30; i++) {
await sleep(1000);
const maxAttempts = this.config.confirmationMaxAttempts ?? DEFAULT_CONFIRMATION_MAX_ATTEMPTS;
const pollIntervalMs = this.config.confirmationPollIntervalMs ?? DEFAULT_CONFIRMATION_POLL_INTERVAL_MS;
for (let i = 0; i < maxAttempts; i++) {
await sleep(pollIntervalMs);
let s;
try {
s = await server.getTransaction(hash);
Expand Down
64 changes: 56 additions & 8 deletions src/tests/soroban-rate-limit.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
import { describe, it, expect, vi } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { RateLimitError } from '../errors.js';

const mockSimulateTransaction = vi.fn();
const mockGetAccount = vi.fn();
const mockSendTransaction = vi.fn();
const mockGetTransaction = vi.fn();
const {
mockSimulateTransaction,
mockGetAccount,
mockSendTransaction,
mockGetTransaction,
mockAssembleTransaction,
} = vi.hoisted(() => ({
mockSimulateTransaction: vi.fn(),
mockGetAccount: vi.fn(),
mockSendTransaction: vi.fn(),
mockGetTransaction: vi.fn(),
mockAssembleTransaction: vi.fn(),
}));

vi.mock('@stellar/stellar-sdk', async () => {
const actual = await vi.importActual('@stellar/stellar-sdk');
Expand All @@ -21,13 +30,26 @@ vi.mock('@stellar/stellar-sdk', async () => {
};
}),
Api: (actual as any).SorobanRpc.Api,
assembleTransaction: (actual as any).SorobanRpc.assembleTransaction,
assembleTransaction: mockAssembleTransaction,
},
};
});


import { simulateReadOnly } from '../soroban.js';
import { invokeContract, simulateReadOnly } from '../soroban.js';

beforeEach(() => {
vi.useFakeTimers();
mockSimulateTransaction.mockReset();
mockGetAccount.mockReset();
mockSendTransaction.mockReset();
mockGetTransaction.mockReset();
mockAssembleTransaction.mockReset().mockReturnValue({ build: () => ({ sign: vi.fn() }) });
});

afterEach(() => {
vi.useRealTimers();
});

describe('soroban.ts rate limit handling', () => {
it('converts a 429 thrown by simulateTransaction into a RateLimitError', async () => {
Expand All @@ -49,4 +71,30 @@ describe('soroban.ts rate limit handling', () => {
simulateReadOnly('http://localhost:8000', 'passphrase', {} as any)
).rejects.toThrow('ECONNREFUSED');
});
});

it('uses custom polling interval and attempts in invokeContract()', async () => {
const signer = { publicKey: () => 'GTEST', sign: vi.fn() };
mockSimulateTransaction.mockResolvedValue({ result: { retval: {} }, transactionData: {} });
mockSendTransaction.mockResolvedValue({ status: 'PENDING', hash: 'abc123' });
mockGetTransaction.mockResolvedValue({ status: 'NOT_FOUND' });

const promise = invokeContract(
'http://localhost:8000',
'passphrase',
signer as any,
{} as any,
{ pollIntervalMs: 200, maxAttempts: 2 },
);
promise.catch(() => {});

await vi.advanceTimersByTimeAsync(199);
expect(mockGetTransaction).not.toHaveBeenCalled();

await vi.advanceTimersByTimeAsync(1);
expect(mockGetTransaction).toHaveBeenCalledTimes(1);

await vi.advanceTimersByTimeAsync(200);
await expect(promise).rejects.toThrow('Transaction timed out: abc123');
expect(mockGetTransaction).toHaveBeenCalledTimes(2);
});
});
32 changes: 31 additions & 1 deletion src/tests/streams-success.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,12 @@ const STREAM_ADDR = StrKey.encodeContract(Buffer.alloc(32, 2));
const TOKEN = StrKey.encodeContract(Buffer.alloc(32, 3));
const RECIPIENT = Keypair.random().publicKey();

function makeConfig(): ConduitConfig {
function makeConfig(overrides: Partial<ConduitConfig> = {}): ConduitConfig {
return {
network: 'testnet',
factoryAddress: FACTORY_ADDR,
keypair: Keypair.random(),
...overrides,
};
}

Expand Down Expand Up @@ -237,6 +238,35 @@ describe('StreamsModule.create() — success path', () => {
durationSeconds: 3600,
}))).rejects.toBeInstanceOf(RateLimitError);
});

it('uses configured confirmation polling interval and attempts', async () => {
mockSimulate.mockResolvedValue(simSuccess(u64Scv(1n)));
mockGetTransaction.mockResolvedValue({ status: 'NOT_FOUND' });

const { StreamsModule } = await import('../streams.js');
const sdk = new StreamsModule(makeConfig({
confirmationPollIntervalMs: 250,
confirmationMaxAttempts: 2,
}));

const promise = sdk.create({
recipient: RECIPIENT,
token: TOKEN,
depositAmount: '1000',
durationSeconds: 3600,
});
promise.catch(() => {});

await vi.advanceTimersByTimeAsync(249);
expect(mockGetTransaction).not.toHaveBeenCalled();

await vi.advanceTimersByTimeAsync(1);
expect(mockGetTransaction).toHaveBeenCalledTimes(1);

await vi.advanceTimersByTimeAsync(250);
await expect(promise).rejects.toThrow('Transaction timed out: deadbeef');
expect(mockGetTransaction).toHaveBeenCalledTimes(2);
});
});

describe('StreamsModule.clawback() — success path', () => {
Expand Down
4 changes: 4 additions & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ export interface ConduitConfig {
factoryAddress?: string;
/** Override deployed DripGovernor contract ID */
governorAddress?: string;
/** Override Soroban confirmation poll interval in ms; default 1000 */
confirmationPollIntervalMs?: number;
/** Override Soroban confirmation polling attempts; default 30 */
confirmationMaxAttempts?: number;
}

export interface StreamInfo {
Expand Down
Loading