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
7 changes: 7 additions & 0 deletions .changeset/auto-nonce-management.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@satoshai/playstacks': minor
---

Add automatic nonce management for sequential transactions

Introduces a `NonceTracker` that fetches the initial nonce from the chain and increments it locally after each broadcast, allowing multiple transactions to be sent in a single test without waiting for each to confirm. Nonce tracking resets automatically between tests.
3 changes: 3 additions & 0 deletions packages/playstacks/src/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@ export function testWithStacks(config: PlaystacksConfig) {
const resolved = await resolvedConfigPromise;
const handler = new MockProviderHandler(resolved);

// Reset nonce tracking for each test to avoid stale nonces
handler.resetNonce();

// Install the mock provider before any navigation
await handler.install(page);

Expand Down
17 changes: 17 additions & 0 deletions packages/playstacks/src/wallet/mock-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
import { broadcast } from '../tx/broadcaster.js';
import { getMockProviderScript } from './mock-provider-script.js';
import { hashMessage } from './message-hash.js';
import { NonceTracker } from './nonce-tracker.js';
import type {
WalletIdentity,
WalletRpcRequest,
Expand All @@ -41,6 +42,7 @@ export class MockProviderHandler {
readonly identity: WalletIdentity;
readonly network: ResolvedNetwork;
private readonly config: ResolvedConfig;
private readonly nonceTracker: NonceTracker;
private shouldRejectNext = false;
/** Last broadcast transaction ID, set after successful broadcast */
lastTxId: string | null = null;
Expand All @@ -51,6 +53,12 @@ export class MockProviderHandler {

const isMainnet = this.network.stacksNetwork === 'mainnet';
this.identity = deriveWalletIdentity(config.privateKey, isMainnet);
this.nonceTracker = new NonceTracker(this.network, this.identity.address);
}

/** Reset nonce tracking. Call between tests to avoid stale nonces. */
resetNonce(): void {
this.nonceTracker.reset();
}

/**
Expand Down Expand Up @@ -209,16 +217,20 @@ export class MockProviderHandler {
this.config.fee
);

const nonce = await this.nonceTracker.getNextNonce();

const transaction = await makeSTXTokenTransfer({
recipient: params.recipient,
amount: BigInt(params.amount),
senderKey: this.identity.privateKey,
network: this.network.stacksNetwork,
fee: estimatedFee,
nonce,
memo: params.memo,
});

const result = await broadcast(transaction, this.network);
this.nonceTracker.increment();
this.lastTxId = result.txid;
return result;
}
Expand All @@ -244,10 +256,13 @@ export class MockProviderHandler {
postConditions: params.postConditions,
};

const nonce = await this.nonceTracker.getNextNonce();

// First pass: build unsigned tx to estimate fee
const unsignedTx = await makeContractCall({
...commonOpts,
fee: 0n,
nonce,
});

// Estimate fee using the serialized payload
Expand All @@ -264,9 +279,11 @@ export class MockProviderHandler {
const transaction = await makeContractCall({
...commonOpts,
fee: estimatedFee,
nonce,
});

const result = await broadcast(transaction, this.network);
this.nonceTracker.increment();
this.lastTxId = result.txid;
return result;
}
Expand Down
47 changes: 47 additions & 0 deletions packages/playstacks/src/wallet/nonce-tracker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import type { ResolvedNetwork } from '../network/network-config.js';
import { fetchNonce } from '../network/api-client.js';

/**
* Tracks nonces locally to allow sending multiple transactions
* without waiting for each to confirm.
*
* On first use, fetches the current nonce from the chain.
* After each broadcast, increments the local counter so the
* next transaction uses nonce + 1.
*/
export class NonceTracker {
private readonly network: ResolvedNetwork;
private readonly address: string;
private nextNonce: bigint | null = null;

constructor(network: ResolvedNetwork, address: string) {
this.network = network;
this.address = address;
}

/**
* Get the next nonce to use for a transaction.
* Fetches from the chain on first call, then increments locally.
*/
async getNextNonce(): Promise<bigint> {
if (this.nextNonce === null) {
this.nextNonce = await fetchNonce(this.network, this.address);
}
return this.nextNonce;
}

/**
* Mark the current nonce as used (call after successful broadcast).
* Increments the local counter for the next transaction.
*/
increment(): void {
if (this.nextNonce !== null) {
this.nextNonce += 1n;
}
}

/** Reset to fetch from chain again on next call. */
reset(): void {
this.nextNonce = null;
}
}
94 changes: 94 additions & 0 deletions packages/playstacks/tests/unit/nonce-tracker.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NonceTracker } from '../../src/wallet/nonce-tracker.js';
import type { ResolvedNetwork } from '../../src/network/network-config.js';

vi.mock('../../src/network/api-client.js', () => ({
fetchNonce: vi.fn(),
}));

import { fetchNonce } from '../../src/network/api-client.js';

const mockFetchNonce = vi.mocked(fetchNonce);

const MOCK_NETWORK: ResolvedNetwork = {
stacksNetwork: 'testnet',
apiUrl: 'https://api.testnet.hiro.so',
};

describe('NonceTracker', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('fetches nonce from chain on first call', async () => {
mockFetchNonce.mockResolvedValueOnce(5n);
const tracker = new NonceTracker(MOCK_NETWORK, 'ST123');

const nonce = await tracker.getNextNonce();

expect(nonce).toBe(5n);
expect(mockFetchNonce).toHaveBeenCalledWith(MOCK_NETWORK, 'ST123');
});

it('returns same nonce on repeated calls without increment', async () => {
mockFetchNonce.mockResolvedValueOnce(5n);
const tracker = new NonceTracker(MOCK_NETWORK, 'ST123');

const first = await tracker.getNextNonce();
const second = await tracker.getNextNonce();

expect(first).toBe(5n);
expect(second).toBe(5n);
expect(mockFetchNonce).toHaveBeenCalledTimes(1);
});

it('increments nonce after broadcast', async () => {
mockFetchNonce.mockResolvedValueOnce(5n);
const tracker = new NonceTracker(MOCK_NETWORK, 'ST123');

const first = await tracker.getNextNonce();
tracker.increment();
const second = await tracker.getNextNonce();

expect(first).toBe(5n);
expect(second).toBe(6n);
expect(mockFetchNonce).toHaveBeenCalledTimes(1);
});

it('tracks multiple sequential increments', async () => {
mockFetchNonce.mockResolvedValueOnce(10n);
const tracker = new NonceTracker(MOCK_NETWORK, 'ST123');

const n0 = await tracker.getNextNonce();
tracker.increment();
const n1 = await tracker.getNextNonce();
tracker.increment();
const n2 = await tracker.getNextNonce();

expect(n0).toBe(10n);
expect(n1).toBe(11n);
expect(n2).toBe(12n);
expect(mockFetchNonce).toHaveBeenCalledTimes(1);
});

it('refetches from chain after reset', async () => {
mockFetchNonce.mockResolvedValueOnce(5n);
const tracker = new NonceTracker(MOCK_NETWORK, 'ST123');

await tracker.getNextNonce();
tracker.increment();
tracker.reset();

mockFetchNonce.mockResolvedValueOnce(6n);
const nonce = await tracker.getNextNonce();

expect(nonce).toBe(6n);
expect(mockFetchNonce).toHaveBeenCalledTimes(2);
});

it('increment is a no-op before first fetch', () => {
const tracker = new NonceTracker(MOCK_NETWORK, 'ST123');
// Should not throw
tracker.increment();
});
});