diff --git a/packages/transactions/src/builders.ts b/packages/transactions/src/builders.ts index e827bc1f6..c235f90cc 100644 --- a/packages/transactions/src/builders.ts +++ b/packages/transactions/src/builders.ts @@ -6,6 +6,7 @@ import { StacksTestnet, FetchFn, createFetchFn, + HIRO_TESTNET_DEFAULT, } from '@stacks/network'; import { c32address } from 'c32check'; import { @@ -116,6 +117,37 @@ export async function getNonce( return BigInt(result.nonce); } +/** + * Fetch the network chain ID from a given network. Typically used for looking up the chain ID for a non-default testnet network. + * @param useDefaultOnError - If the network fetch fails then return the default chain ID already specifed in the `network` arg. + */ +export async function getNetworkChainID(network: StacksNetwork, useDefaultOnError = true) { + const url = network.getInfoUrl(); + try { + const response = await network.fetchFn(url); + if (!response.ok) { + const msg = await response.text().catch(() => ''); + throw new Error(`Bad response status ${response.status} ${response.statusText}: "${msg}"`); + } + const responseJson: { network_id: number } = await response.json(); + return responseJson.network_id; + } catch (error) { + if (!useDefaultOnError) { + throw error; + } + // log error and return default nework chain ID + console.warn(`Error fetching network chain ID from ${url}`, error); + return network.chainId; + } +} + +function isNetworkCustomTestnet(network: StacksNetwork) { + return ( + network.version !== TransactionVersion.Mainnet && + new URL(network.coreApiUrl).host !== new URL(HIRO_TESTNET_DEFAULT).host + ); +} + /** * @deprecated Use the new {@link estimateTransaction} function instead. * @@ -751,6 +783,11 @@ export async function makeUnsignedSTXTokenTransfer( transaction.setNonce(txNonce); } + // Lookup chain ID for (non-primary) testnet networks + if (isNetworkCustomTestnet(network)) { + transaction.chainId = await getNetworkChainID(network); + } + return transaction; } @@ -1024,6 +1061,11 @@ export async function makeUnsignedContractDeploy( transaction.setNonce(txNonce); } + // Lookup chain ID for (non-primary) testnet networks + if (isNetworkCustomTestnet(network)) { + transaction.chainId = await getNetworkChainID(network); + } + return transaction; } @@ -1237,6 +1279,11 @@ export async function makeUnsignedContractCall( transaction.setNonce(txNonce); } + // Lookup chain ID for (non-primary) testnet networks + if (isNetworkCustomTestnet(network)) { + transaction.chainId = await getNetworkChainID(network); + } + return transaction; } diff --git a/packages/transactions/tests/builder.test.ts b/packages/transactions/tests/builder.test.ts index cbf5cea2a..2da995c13 100644 --- a/packages/transactions/tests/builder.test.ts +++ b/packages/transactions/tests/builder.test.ts @@ -1,4 +1,5 @@ import { + ChainID, PRIVATE_KEY_COMPRESSED_LENGTH, bytesToHex, bytesToUtf8, @@ -332,6 +333,47 @@ test('Make STX token transfer with testnet string name', async () => { expect(serialized).toBe(tx); }); +test('Make STX token transfer with custom testnet (lookup network chainID)', async () => { + const nonce = 123; + const recipient = standardPrincipalCV('SP3FGQ8Z7JY9BWYZ5WM53E0M9NK7WHJF0691NZ159'); + const amount = 12345; + const fee = 0; + const senderKey = 'cb3df38053d132895220b9ce471f6b676db5b9bf0b4adefb55f2118ece2478df01'; + const senderAddress = 'STB44HYPYAT2BB2QE513NSP81HTMYWBJP02HPGK6'; + const memo = 'test memo'; + const network = new StacksTestnet({ url: 'https://my-custom-testnet.example' }); + 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}}`); + + // http://localhost:3999/v2/info + fetchMock.once(JSON.stringify({ network_id: 0x1234 })); + + const transaction = await makeSTXTokenTransfer({ + recipient, + amount, + senderKey, + fee, + memo, + network, + anchorMode: AnchorMode.Any, + }); + + expect(fetchMock.mock.calls.length).toEqual(5); + expect(fetchMock.mock.calls[1][0]).toEqual(apiUrl); + expect(fetchMock.mock.calls[3][0]).toEqual(apiUrl); + expect(fetchMock.mock.calls[4][0]).toEqual(network.getInfoUrl()); + expect(fetchNonce.toString()).toEqual(nonce.toString()); + expect(transaction.auth.spendingCondition?.nonce?.toString()).toEqual(nonce.toString()); + expect(transaction.chainId).toEqual(0x1234); +}); + test('Throws making STX token transder with invalid network name', async () => { const txOptions = { recipient: standardPrincipalCV('SP3FGQ8Z7JY9BWYZ5WM53E0M9NK7WHJF0691NZ159'), @@ -1242,6 +1284,9 @@ test('Estimate transaction fee fallback', async () => { // http://localhost:3999/v2/fees/transfer fetchMock.once('1'); + // http://localhost:3999/v2/info + fetchMock.once(JSON.stringify({ network_id: ChainID.Testnet })); + const tx = await makeContractCall({ senderKey: privateKey, contractAddress: 'ST000000000000000000002AMW42H', @@ -1291,7 +1336,7 @@ test('Estimate transaction fee fallback', async () => { const doubleRate = await estimateTransactionFeeWithFallback(tx, testnet); expect(doubleRate).toBe(402n); - expect(fetchMock.mock.calls.length).toEqual(8); + expect(fetchMock.mock.calls.length).toEqual(9); }); test('Single-sig transaction byte length must include signature', async () => {