diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 5f542b27f..8dc7b57b9 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -53,7 +53,7 @@ import * as path from 'path'; const c32check = require('c32check'); import { UserData } from '@stacks/auth'; -import crossfetch from 'cross-fetch'; +import 'cross-fetch/polyfill'; import { StackerInfo, StackingClient } from '@stacks/stacking'; @@ -1735,7 +1735,6 @@ async function canStack(network: CLINetworkAdapter, args: string[]): Promise { // console.log(address); const apiConfig = new Configuration({ - fetchApi: crossfetch, basePath: 'https://api.testnet.hiro.so', }); diff --git a/packages/cli/tests/cli.test.ts b/packages/cli/tests/cli.test.ts index 4c73dba1a..2670adc0e 100644 --- a/packages/cli/tests/cli.test.ts +++ b/packages/cli/tests/cli.test.ts @@ -284,6 +284,7 @@ describe('BNS', () => { const mockedResponse = JSON.stringify(TEST_FEE_ESTIMATE); fetchMock.mockOnce(mockedResponse); + fetchMock.mockRejectOnce(); fetchMock.mockOnce(JSON.stringify({ nonce: 1000 })); fetchMock.mockOnce(JSON.stringify('success')); @@ -303,6 +304,7 @@ describe('BNS', () => { const mockedResponse = JSON.stringify(TEST_FEE_ESTIMATE); fetchMock.mockOnce(mockedResponse); + fetchMock.mockRejectOnce(); fetchMock.mockOnce(JSON.stringify({ nonce: 1000 })); fetchMock.mockOnce(JSON.stringify('success')); @@ -323,7 +325,7 @@ describe('Subdomain Migration', () => { string, string, { txid: string; error: string | null; status: number } | string, - boolean + boolean, ][] = [ [ 'sound idle panel often situate develop unit text design antenna vendor screen opinion balcony share trigger accuse scatter visa uniform brass update opinion media', @@ -429,6 +431,9 @@ describe('Subdomain Migration', () => { test('can_stack', async () => { fetchMock.resetMocks(); + fetchMock.mockOnce( + `{"stx":{"balance":"16216000000000","total_sent":"0","total_received":"0","total_fees_sent":"0","total_miner_rewards_received":"0","lock_tx_id":"","locked":"0","lock_height":0,"burnchain_lock_height":0,"burnchain_unlock_height":0},"fungible_tokens":{},"non_fungible_tokens":{}}` + ); fetchMock.mockOnce( '{"contract_id":"ST000000000000000000002AMW42H.pox","pox_activation_threshold_ustx":827381723155441,"first_burnchain_block_height":2000000,"prepare_phase_block_length":50,"reward_phase_block_length":1000,"reward_slots":2000,"rejection_fraction":12,"total_liquid_supply_ustx":41369086157772050,"current_cycle":{"id":269,"min_threshold_ustx":5180000000000,"stacked_ustx":0,"is_pox_active":false},"next_cycle":{"id":270,"min_threshold_ustx":5180000000000,"min_increment_ustx":5171135769721,"stacked_ustx":5600000000000,"prepare_phase_start_block_height":2283450,"blocks_until_prepare_phase":146,"reward_phase_start_block_height":2283500,"blocks_until_reward_phase":196,"ustx_until_pox_rejection":4964290338932640},"min_amount_ustx":5180000000000,"prepare_cycle_length":50,"reward_cycle_id":269,"reward_cycle_length":1050,"rejection_votes_left_required":4964290338932640,"next_reward_cycle_in":196}' ); @@ -446,9 +451,9 @@ test('can_stack', async () => { const response = await canStack(testnetNetwork, params.split(' ')); expect(response.eligible).toBe(true); - expect(fetchMock.mock.calls).toHaveLength(4); - expect(fetchMock.mock.calls[3][0]).toContain('/pox/can-stack-stx'); - expect(fetchMock.mock.calls[3][1]?.body).toBe( + expect(fetchMock.mock.calls).toHaveLength(5); + expect(fetchMock.mock.calls[4][0]).toContain('/pox/can-stack-stx'); + expect(fetchMock.mock.calls[4][1]?.body).toBe( '{"sender":"ST3VJVZ265JZMG1N61YE3EQ7GNTQHF6PXP0E7YACV","arguments":["0x0c000000020968617368627974657302000000147046a658021260485e1ba9eb6c3e4c26b60953290776657273696f6e020000000100","0x010000000000000000000005a74678d000","0x010000000000000000000000000000010d","0x010000000000000000000000000000000a"]}' ); }); diff --git a/packages/transactions/src/builders.ts b/packages/transactions/src/builders.ts index 24219d19d..25d9d7a9b 100644 --- a/packages/transactions/src/builders.ts +++ b/packages/transactions/src/builders.ts @@ -70,21 +70,33 @@ import { StacksTransaction } from './transaction'; import { createLPList } from './types'; import { cvToHex, omit, parseReadOnlyResponse, validateTxId } from './utils'; +/** @internal */ +async function _getNonceApi(address: string, network: StacksNetwork): Promise { + const url = `${network.coreApiUrl}/extended/v1/address/${address}/nonces`; + const response = await network.fetchFn(url); + const result = await response.json(); + return BigInt(result.possible_next_nonce); +} + /** - * Lookup the nonce for an address from a core node - * - * @param {string} address - the c32check address to look up - * @param {StacksNetworkName | StacksNetwork} network - the Stacks network to look up address on - * - * @return a promise that resolves to an integer + * Lookup the nonce for an address from an API or core node + * @return a promise that resolves to a bigint */ export async function getNonce( + /** The Stacks (c32check) address to look up */ address: string, + /** The Stacks network to look up the address on */ network?: StacksNetworkName | StacksNetwork ): Promise { const derivedNetwork = StacksNetwork.fromNameOrNetwork(network ?? new StacksMainnet()); const url = derivedNetwork.getAccountApiUrl(address); + // Try API first + try { + return await _getNonceApi(address, derivedNetwork); + } catch (e) {} + + // Try node if API endpoint isn't available const response = await derivedNetwork.fetchFn(url); if (!response.ok) { let msg = ''; diff --git a/packages/transactions/tests/builder.test.ts b/packages/transactions/tests/builder.test.ts index 2920cd26a..690d0f956 100644 --- a/packages/transactions/tests/builder.test.ts +++ b/packages/transactions/tests/builder.test.ts @@ -120,12 +120,13 @@ test('API key middleware - get nonce', async () => { const fetchFn = createFetchFn(createApiKeyMiddleware({ apiKey })); const network = new StacksMainnet({ fetchFn }); + fetchMock.mockRejectOnce(); fetchMock.mockOnce(`{"balance": "0", "nonce": "123"}`); const fetchNonce = await getNonce(senderAddress, network); expect(fetchNonce).toBe(123n); - expect(fetchMock.mock.calls.length).toEqual(1); - expect(fetchMock.mock.calls[0][0]).toEqual( + expect(fetchMock.mock.calls.length).toEqual(2); + expect(fetchMock.mock.calls[1][0]).toEqual( 'https://api.mainnet.hiro.so/v2/accounts/STB44HYPYAT2BB2QE513NSP81HTMYWBJP02HPGK6?proof=0' ); const callHeaders = new Headers(fetchMock.mock.calls[0][1]?.headers); @@ -1404,10 +1405,12 @@ test('Make STX token transfer with fetch account nonce', async () => { const network = new StacksTestnet(); const apiUrl = network.getAccountApiUrl(senderAddress); + fetchMock.mockRejectOnce(); fetchMock.mockOnce(`{"balance":"0", "nonce":${nonce}}`); const fetchNonce = await getNonce(senderAddress, network); + fetchMock.mockRejectOnce(); fetchMock.mockOnce(`{"balance":"0", "nonce":${nonce}}`); const transaction = await makeSTXTokenTransfer({ @@ -1420,9 +1423,9 @@ test('Make STX token transfer with fetch account nonce', async () => { anchorMode: AnchorMode.Any, }); - expect(fetchMock.mock.calls.length).toEqual(2); - expect(fetchMock.mock.calls[0][0]).toEqual(apiUrl); + expect(fetchMock.mock.calls.length).toEqual(4); expect(fetchMock.mock.calls[1][0]).toEqual(apiUrl); + expect(fetchMock.mock.calls[3][0]).toEqual(apiUrl); expect(fetchNonce.toString()).toEqual(nonce.toString()); expect(transaction.auth.spendingCondition?.nonce?.toString()).toEqual(nonce.toString()); }); @@ -1786,12 +1789,13 @@ test('Make sponsored contract call with sponsor nonce fetch', async () => { fee: sponsorFee, }; + fetchMock.mockRejectOnce(); fetchMock.mockOnce(`{"balance":"100000", "nonce":${sponsorNonce}}`); const sponsorSignedTx = await sponsorTransaction(sponsorOptions); - expect(fetchMock.mock.calls.length).toEqual(1); - expect(fetchMock.mock.calls[0][0]).toEqual(network.getAccountApiUrl(sponsorAddress)); + expect(fetchMock.mock.calls.length).toEqual(2); + expect(fetchMock.mock.calls[1][0]).toEqual(network.getAccountApiUrl(sponsorAddress)); const sponsorSignedTxSerialized = sponsorSignedTx.serialize(); @@ -2164,6 +2168,42 @@ test('Get contract map entry - no match', async () => { expect(result.type).toBe(ClarityType.OptionalNone); }); +describe(getNonce.name, () => { + test('without API', async () => { + const nonce = 123n; + const address = 'STB44HYPYAT2BB2QE513NSP81HTMYWBJP02HPGK6'; + + const network = new StacksTestnet(); + + fetchMock.mockRejectOnce(); // missing API + fetchMock.mockOnce(`{"balance":"0", "nonce":${nonce}}`); + + await expect(getNonce(address, network)).resolves.toEqual(nonce); + + expect(fetchMock.mock.calls.length).toEqual(2); + expect(fetchMock.mock.calls[0][0]).toContain('https://api.testnet.hiro.so/extended/'); + expect(fetchMock.mock.calls[1][0]).toContain('https://api.testnet.hiro.so/v2/'); + }); + + test('with API', async () => { + const nonce = 123n; + const address = 'STB44HYPYAT2BB2QE513NSP81HTMYWBJP02HPGK6'; + + const network = new StacksTestnet(); + + fetchMock.mockOnce( + `{"last_executed_tx_nonce":${nonce - 2n},"last_mempool_tx_nonce":${ + nonce - 1n + },"possible_next_nonce":${nonce},"detected_missing_nonces":[],"detected_mempool_nonces":[]}` + ); + + await expect(getNonce(address, network)).resolves.toEqual(nonce); + + expect(fetchMock.mock.calls.length).toEqual(1); + expect(fetchMock.mock.calls[0][0]).toContain('https://api.testnet.hiro.so/extended/'); + }); +}); + test('Post-conditions with amount larger than 8 bytes throw an error', () => { const amount = BigInt('0xffffffffffffffff') + 1n;