diff --git a/modules/sdk-coin-stx/src/lib/index.ts b/modules/sdk-coin-stx/src/lib/index.ts index 2ca71e4d87..e40977f692 100644 --- a/modules/sdk-coin-stx/src/lib/index.ts +++ b/modules/sdk-coin-stx/src/lib/index.ts @@ -1,5 +1,6 @@ export { AddressVersion, AddressHashMode } from '@stacks/transactions'; export * from './keyPair'; +export * from './network'; export * from './transaction'; export * from './transactionBuilderFactory'; export * from './sbtcWithdrawBuilder'; diff --git a/modules/sdk-coin-stx/src/lib/network.ts b/modules/sdk-coin-stx/src/lib/network.ts new file mode 100644 index 0000000000..9396789504 --- /dev/null +++ b/modules/sdk-coin-stx/src/lib/network.ts @@ -0,0 +1,28 @@ +import { BaseCoin as CoinConfig, NetworkType, StacksNetwork as StaticsStacksNetwork } from '@bitgo/statics'; +import { StacksMainnet, StacksNetwork, StacksTestnet } from '@stacks/network'; + +/** + * Build the `@stacks/network` object a transaction is serialized against. + * + * The stock Stacks mainnet/testnet networks hardcode their chain ID and transaction version. A + * statics network that describes a different Stacks deployment (for example the Stacks + * Foundation `staking-testnet`, chain ID 1280, on a private Bitcoin signet) declares + * `chainId` / `transactionVersion`; those are applied on top of the stock network so the + * serialized transaction carries the chain ID the node expects. + * + * When statics omits both fields the returned network is exactly the stock one, so existing + * `stx` / `tstx` transactions stay byte-identical. + */ +export function toStacksNetwork(coinConfig: Readonly): StacksNetwork { + const staticsNetwork = coinConfig.network as StaticsStacksNetwork; + const network = staticsNetwork.type === NetworkType.MAINNET ? new StacksMainnet() : new StacksTestnet(); + + if (staticsNetwork.chainId !== undefined) { + network.chainId = staticsNetwork.chainId; + } + if (staticsNetwork.transactionVersion !== undefined) { + network.version = staticsNetwork.transactionVersion; + } + + return network; +} diff --git a/modules/sdk-coin-stx/src/lib/transactionBuilder.ts b/modules/sdk-coin-stx/src/lib/transactionBuilder.ts index a27c198fa9..dd5f73357f 100644 --- a/modules/sdk-coin-stx/src/lib/transactionBuilder.ts +++ b/modules/sdk-coin-stx/src/lib/transactionBuilder.ts @@ -1,6 +1,6 @@ import BigNumber from 'bignumber.js'; import BigNum from 'bn.js'; -import { BaseCoin as CoinConfig, NetworkType } from '@bitgo/statics'; +import { BaseCoin as CoinConfig } from '@bitgo/statics'; import { AuthType, BufferReader, @@ -13,7 +13,7 @@ import { StacksMessageType, PubKeyEncoding, } from '@stacks/transactions'; -import { StacksNetwork, StacksTestnet, StacksMainnet } from '@stacks/network'; +import { StacksNetwork } from '@stacks/network'; import { BaseAddress, BaseFee, @@ -31,6 +31,7 @@ import { KeyPair } from './keyPair'; import { SignatureData } from './iface'; import { isValidAddress, removeHexPrefix, isValidMemo, isValidPublicKey } from './utils'; import { ANCHOR_MODE, DEFAULT_MULTISIG_SIG_NUMBER } from './constants'; +import { toStacksNetwork } from './network'; export abstract class TransactionBuilder extends BaseTransactionBuilder { private _transaction: Transaction; @@ -51,7 +52,7 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder { this._fromPubKeys = []; this._signatures = []; this._numberSignatures = DEFAULT_MULTISIG_SIG_NUMBER; - this._network = _coinConfig.network.type === NetworkType.MAINNET ? new StacksMainnet() : new StacksTestnet(); + this._network = toStacksNetwork(_coinConfig); this._transaction = new Transaction(_coinConfig); } diff --git a/modules/sdk-coin-stx/test/unit/network.ts b/modules/sdk-coin-stx/test/unit/network.ts new file mode 100644 index 0000000000..7592b0daa1 --- /dev/null +++ b/modules/sdk-coin-stx/test/unit/network.ts @@ -0,0 +1,62 @@ +import { BaseCoin as CoinConfig, NetworkType, coins } from '@bitgo/statics'; +import { pubKeyfromPrivKey, publicKeyToString } from '@stacks/transactions'; +import * as assert from 'assert'; +import { StxLib } from '../../src'; + +const { toStacksNetwork, TransactionBuilderFactory } = StxLib; + +describe('Stacks network plumbing', function () { + it('keeps the stock mainnet chain identity when statics declares none', function () { + const network = toStacksNetwork({ network: { type: NetworkType.MAINNET } } as unknown as CoinConfig); + assert.strictEqual(network.chainId, 1); + assert.strictEqual(network.version, 0); + }); + + it('keeps the stock testnet chain identity when statics declares none', function () { + const network = toStacksNetwork({ network: { type: NetworkType.TESTNET } } as unknown as CoinConfig); + assert.strictEqual(network.chainId, 2147483648); + assert.strictEqual(network.version, 128); + }); + + it('applies a chain identity declared in statics', function () { + const network = toStacksNetwork({ + network: { type: NetworkType.TESTNET, chainId: 1280, transactionVersion: 0x80 }, + } as unknown as CoinConfig); + assert.strictEqual(network.chainId, 1280); + assert.strictEqual(network.version, 0x80); + }); + + // staking-testnet rejects transactions carrying the public testnet chain ID with + // "invalid chain ID 2147483648 (expected 1280)", so the chain ID has to reach the bytes. + it('serializes a statics-declared chain ID into the transaction', async function () { + const txHex = await buildTransfer(stakingTestnetCoinConfig()); + assert.strictEqual(txHex.slice(0, 10), '8000000500'); + }); + + it('leaves public testnet transactions on the stock chain ID', async function () { + const txHex = await buildTransfer(coins.get('tstx') as unknown as CoinConfig); + assert.strictEqual(txHex.slice(0, 10), '8080000000'); + }); +}); + +function stakingTestnetCoinConfig(): CoinConfig { + const tstx = coins.get('tstx'); + return { + ...tstx, + network: { ...tstx.network, chainId: 1280, transactionVersion: 0x80 }, + } as unknown as CoinConfig; +} + +async function buildTransfer(config: CoinConfig): Promise { + const publicKey = publicKeyToString( + pubKeyfromPrivKey('0f4fad1041051740108cdf523c346199e99d80cc845a4ea8d4d29b1a81fd22fc01') + ); + const builder = new TransactionBuilderFactory(config).getTransferBuilder(); + builder.to('STHT87WKW17VGMPFFNB6QD1ABNKGB1600ZE7YVW4'); + builder.amount('1000'); + builder.fee({ fee: '200' }); + builder.nonce(0); + builder.fromPubKey([publicKey]); + const transaction = await builder.build(); + return transaction.toBroadcastFormat(); +} diff --git a/modules/statics/src/networks.ts b/modules/statics/src/networks.ts index aafe144b11..3664bbdd11 100644 --- a/modules/statics/src/networks.ts +++ b/modules/statics/src/networks.ts @@ -202,6 +202,13 @@ export interface StacksNetwork extends AccountNetwork { readonly sendmanymemoContractAddress: string; readonly stakingContractAddress: string; readonly sbtcWithdrawalContractAddress: string; + /** + * Chain ID carried in the serialized transaction. Omitted for the stock Stacks mainnet/testnet + * deployments, whose chain ID is implied by the network type. + */ + readonly chainId?: number; + /** Transaction version byte. Omitted for the stock Stacks mainnet/testnet deployments. */ + readonly transactionVersion?: number; } // eslint-disable-next-line @typescript-eslint/no-empty-interface @@ -1358,6 +1365,27 @@ class StxTestnet extends Testnet implements StacksNetwork { sbtcWithdrawalContractAddress = 'SN69P7RZRKK8ERQCCABHT2JWKB2S4DHH9H74231T'; } +/** + * Stacks Foundation `staking-testnet`: a PoX-5 testnet running on a private Bitcoin signet. + * + * It is a distinct Stacks network (chain ID 1280) that shares the public testnet transaction + * version and address versions, so a transaction built for the public testnet is rejected by its + * node with `invalid chain ID 2147483648 (expected 1280)`. The chain ID therefore travels in + * statics rather than being implied by the network type. + */ +class StxStakingTestnet extends Testnet implements StacksNetwork { + name = 'StxStakingTestnet'; + family = CoinFamily.STX; + explorerUrl = 'https://explorer.hiro.so/txid/?chain=testnet'; + // Boot address on this network; the PoX-5 contract is `.pox-5` (verified via /v2/pox). + stakingContractAddress = 'ST000000000000000000002AMW42H'; + // Verified deployed on staking-testnet on 2026-09-26. `send-many-memo` is NOT deployed there. + sbtcWithdrawalContractAddress = 'SN3R84XZYA63QS28932XQF3G1J8R9PC3W76P9CSQS'; + sendmanymemoContractAddress = ''; + chainId = 1280; + transactionVersion = 0x80; +} + class SUSD extends Mainnet implements AccountNetwork { name = 'SUSD'; family = CoinFamily.SUSD; @@ -3177,6 +3205,7 @@ export const Networks = { sui: Object.freeze(new SuiTestnet()), near: Object.freeze(new NearTestnet()), stx: Object.freeze(new StxTestnet()), + stxStakingTestnet: Object.freeze(new StxStakingTestnet()), stt: Object.freeze(new SomniaTestnet()), scrolleth: Object.freeze(new ScrollEthTestnet()), soneium: Object.freeze(new SoneiumTestnet()),