From 22c6bf291c8a625be558c0fc953b6234cf81a53c Mon Sep 17 00:00:00 2001 From: satoshai-dev <262845409+satoshai-dev@users.noreply.github.com> Date: Thu, 26 Feb 2026 13:35:52 +0000 Subject: [PATCH] feat: automatic nonce management for sequential transactions Add NonceTracker that fetches initial nonce from chain and increments locally after each broadcast. This allows sending multiple transactions in a single test without waiting for each to confirm. Nonce tracking resets automatically between tests via fixture teardown. Closes #3 Co-Authored-By: Claude Opus 4.6 --- .changeset/auto-nonce-management.md | 7 ++ packages/playstacks/src/fixtures.ts | 3 + .../playstacks/src/wallet/mock-provider.ts | 17 ++++ .../playstacks/src/wallet/nonce-tracker.ts | 47 ++++++++++ .../tests/unit/nonce-tracker.test.ts | 94 +++++++++++++++++++ 5 files changed, 168 insertions(+) create mode 100644 .changeset/auto-nonce-management.md create mode 100644 packages/playstacks/src/wallet/nonce-tracker.ts create mode 100644 packages/playstacks/tests/unit/nonce-tracker.test.ts diff --git a/.changeset/auto-nonce-management.md b/.changeset/auto-nonce-management.md new file mode 100644 index 0000000..8a79fd0 --- /dev/null +++ b/.changeset/auto-nonce-management.md @@ -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. diff --git a/packages/playstacks/src/fixtures.ts b/packages/playstacks/src/fixtures.ts index 9be364a..033849a 100644 --- a/packages/playstacks/src/fixtures.ts +++ b/packages/playstacks/src/fixtures.ts @@ -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); diff --git a/packages/playstacks/src/wallet/mock-provider.ts b/packages/playstacks/src/wallet/mock-provider.ts index 3e08c7e..45f7603 100644 --- a/packages/playstacks/src/wallet/mock-provider.ts +++ b/packages/playstacks/src/wallet/mock-provider.ts @@ -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, @@ -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; @@ -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(); } /** @@ -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; } @@ -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 @@ -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; } diff --git a/packages/playstacks/src/wallet/nonce-tracker.ts b/packages/playstacks/src/wallet/nonce-tracker.ts new file mode 100644 index 0000000..faa7357 --- /dev/null +++ b/packages/playstacks/src/wallet/nonce-tracker.ts @@ -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 { + 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; + } +} diff --git a/packages/playstacks/tests/unit/nonce-tracker.test.ts b/packages/playstacks/tests/unit/nonce-tracker.test.ts new file mode 100644 index 0000000..9ed7624 --- /dev/null +++ b/packages/playstacks/tests/unit/nonce-tracker.test.ts @@ -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(); + }); +});