From 754db4e2f1532566d176a01c0ffc66d5cd9d7f38 Mon Sep 17 00:00:00 2001 From: Doddanna17 Date: Sat, 17 Oct 2026 10:49:08 +0530 Subject: [PATCH 1/2] feat(sdk-coin-stx): migrate @stacks/transactions to v7.6.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump @stacks/transactions 2.0.1 -> 7.6.0 (and @stacks/network ^4.3.0 -> ^7.6.0). v7 is required to build the SIP-044 staking post-condition (PostConditionType.Staking = 3), the blocker for PoX-5 register-for-bond (SI-1936). Port the sdk-coin-stx src + tests to the v7 API, keeping serialized transactions byte-identical. v7 renames (BufferReader -> BytesReader, StacksTransaction -> StacksTransactionWire, StacksMessageType -> StacksWireType, sigHashPreSign, publicKeyFromSignatureVrs) and moves most post-condition builders to the `Pc` builder namespace. Behavioural adaptations required by v7 (not just renames): - contractBuilder: detect raw ClarityValues with a positive shape check (the v2 numeric-enum reverse lookup breaks under v7 string enums); a malformed JSON descriptor still hits `parseCv`'s validation. - abstractContractBuilder.initBuilder: read clarity args from the raw payload instead of toJson()'s stringified shape. - Default an unset post-condition mode to Deny (v7 throws on explicit undefined). - sign()/appendOrigin(): avoid the stricter v7 TransactionSigner constructor. - Reset the multisig auth-field list that v7 pre-populates in makeUnsigned*, and append the origin pubkey as given (v2 semantics) with the deserialized pubkey normalised to compressed in initBuilder — keeps the auth-field bytes identical to v2 and avoids duplicate entries on a rebuild. - isValidMemo: reject non-string memos explicitly (v7's exceedsMaxLengthBytes no longer throws on non-strings). - Remove dead `_anchorMode` / `ANCHOR_MODE` (anchorMode was dropped from options). Add `src/lib/postConditions.ts` with `makeStakingPostCondition` (the SIP-044 type-3 staking post-condition builder, the SI-1936 enabler). No other new exports are added — the module's export surface is otherwise unchanged. Also fix `findContractTokenNameUsingContract`: its guard was `sip10Token ? sip10Token[0].assetId.split('::')[1] : undefined`, but `filter()` always returns an array — and an empty array is truthy — so an unknown contract dereferenced `sip10Token[0].assetId` and threw a TypeError instead of returning `undefined`. Callers now get the intended 'invalid contract address or contract name, not supported' error. The same always-truthy pattern is tidied in `findTokenNameByContract` (harmless there, but identical intent). The fungible-token builder also fails fast on an unresolved token name: it now throws an InvalidParameterValueError instead of serialising an 'undefined' asset name, which could never match a Deny-mode post-condition and would only abort on-chain, burning the fee. Tests: `stx.pox5RegisterForBond`-style module coverage plus a `pox5Builder` test that builds register-for-bond with `PostConditionMode.Deny` + a type-3 staking post-condition, serializes, rebuilds via `factory.from(hex)` and asserts byte-identical output. BREAKING CHANGE: @stacks/transactions v7 changes the Clarity representation, so the JSON returned by sdk-coin-stx `toJson()` / `explainTransaction` (`payload.functionArgs`) changes shape: Clarity type tags are now strings (`'uint'` vs `1`) and principal/buffer values use the v7 form (`{ type: 'address', value: }` — prefix varies by network/version, e.g. SP/ST/SN/SM; `{ type: 'buffer', value: }`) instead of v2 (`{ type: 5, address: { version, hash160 } }`, `{ type: 2, buffer }`). Consumers that read `functionArgs` (Wallet Platform, policy service, OVC) must expect the v7 shape. Serialized transaction bytes are unchanged (byte-parity preserved) — signing and the HSM are unaffected. Ticket: SI-2024 --- modules/sdk-coin-stx/package.json | 4 +- .../src/lib/abstractContractBuilder.ts | 23 ++-- modules/sdk-coin-stx/src/lib/constants.ts | 2 - .../sdk-coin-stx/src/lib/contractBuilder.ts | 33 +++++- .../src/lib/fungibleTokenTransferBuilder.ts | 25 ++-- modules/sdk-coin-stx/src/lib/index.ts | 1 + modules/sdk-coin-stx/src/lib/keyPair.ts | 8 +- modules/sdk-coin-stx/src/lib/network.ts | 7 +- .../sdk-coin-stx/src/lib/postConditions.ts | 80 +++++++++++++ .../src/lib/sbtcWithdrawBuilder.ts | 31 +++-- .../sdk-coin-stx/src/lib/sendmanyBuilder.ts | 17 ++- modules/sdk-coin-stx/src/lib/transaction.ts | 59 +++++----- .../src/lib/transactionBuilder.ts | 42 ++++--- .../src/lib/transactionBuilderFactory.ts | 6 +- .../sdk-coin-stx/src/lib/transferBuilder.ts | 1 - modules/sdk-coin-stx/src/lib/utils.ts | 62 +++++----- modules/sdk-coin-stx/src/stx.ts | 31 +++-- modules/sdk-coin-stx/test/fixtures.ts | 27 ++--- modules/sdk-coin-stx/test/unit/keyPair.ts | 2 +- modules/sdk-coin-stx/test/unit/network.ts | 12 +- .../sdk-coin-stx/test/unit/postConditions.ts | 64 +++++++++++ modules/sdk-coin-stx/test/unit/resources.ts | 2 +- modules/sdk-coin-stx/test/unit/stx.ts | 4 +- .../transactionBuilder/contractBuilder.ts | 17 ++- .../fungibleTokenTransferBuilder.ts | 6 +- .../unit/transactionBuilder/pox5Builder.ts | 48 ++++++-- .../transactionBuilder/sbtcWithdrawBuilder.ts | 55 +++++---- .../transactionBuilder/sendmanyBuilder.ts | 19 ++-- .../transactionBuilder/transferBuilder.ts | 6 +- modules/sdk-coin-stx/test/unit/tstxsignet.ts | 2 +- modules/sdk-coin-stx/test/unit/util.ts | 47 +++++--- yarn.lock | 107 ++---------------- 32 files changed, 501 insertions(+), 349 deletions(-) create mode 100644 modules/sdk-coin-stx/src/lib/postConditions.ts create mode 100644 modules/sdk-coin-stx/test/unit/postConditions.ts diff --git a/modules/sdk-coin-stx/package.json b/modules/sdk-coin-stx/package.json index b85563df45c..7452d1c7e82 100644 --- a/modules/sdk-coin-stx/package.json +++ b/modules/sdk-coin-stx/package.json @@ -44,8 +44,8 @@ "@bitgo/secp256k1": "^1.11.1", "@bitgo/statics": "^59.23.1", "@noble/curves": "1.8.1", - "@stacks/network": "^4.3.0", - "@stacks/transactions": "2.0.1", + "@stacks/network": "^7.6.0", + "@stacks/transactions": "7.6.0", "bech32": "^2.0.0", "bignumber.js": "^9.0.0", "bn.js": "^5.2.1", diff --git a/modules/sdk-coin-stx/src/lib/abstractContractBuilder.ts b/modules/sdk-coin-stx/src/lib/abstractContractBuilder.ts index 922ba13f941..68143c4d653 100644 --- a/modules/sdk-coin-stx/src/lib/abstractContractBuilder.ts +++ b/modules/sdk-coin-stx/src/lib/abstractContractBuilder.ts @@ -1,12 +1,15 @@ import { BaseCoin as CoinConfig } from '@bitgo/statics'; import BigNum from 'bn.js'; import { + addressToString, ClarityValue, ContractCallOptions, + ContractCallPayload, makeUnsignedContractCall, PayloadType, PostCondition, PostConditionMode, + PostConditionWire, UnsignedContractCallOptions, UnsignedMultiSigContractCallOptions, } from '@stacks/transactions'; @@ -25,7 +28,7 @@ export abstract class AbstractContractBuilder extends TransactionBuilder { protected _contractName: string; protected _functionName: string; protected _postConditionMode?: PostConditionMode; - protected _postConditions?: PostCondition[]; + protected _postConditions?: (PostCondition | PostConditionWire | string)[]; protected _functionArgs: ClarityValue[]; constructor(_coinConfig: Readonly) { @@ -38,16 +41,20 @@ export abstract class AbstractContractBuilder extends TransactionBuilder { throw new InvalidTransactionError('payload must not be undefined'); } if (txData.payload.payloadType === PayloadType.ContractCall) { - this._contractAddress = txData.payload.contractAddress; - this._contractName = txData.payload.contractName; - this._functionName = txData.payload.functionName; - this._functionArgs = txData.payload.functionArgs; + // Read the contract call fields from the deserialized payload. `toJson()` stringifies the + // clarity args (plain `{ type, data | list }` objects) for API consumers, and those are not + // valid ClarityValues under v7 — use the raw payload values so a rebuild stays byte-identical. + const payload = tx.stxTransaction.payload as ContractCallPayload; + this._contractAddress = addressToString(payload.contractAddress); + this._contractName = payload.contractName.content; + this._functionName = payload.functionName.content; + this._functionArgs = payload.functionArgs; // Restore post-conditions from the deserialized tx — `toJson()` does not carry them, so // without this a re-build (e.g. the fee re-build via `factory.from(hex)`) would drop them and // a `Deny`-mode contract call would abort on-chain (SIP-005 post-condition check). if (tx.stxTransaction) { this._postConditionMode = tx.stxTransaction.postConditionMode; - this._postConditions = tx.stxTransaction.postConditions.values as PostCondition[]; + this._postConditions = tx.stxTransaction.postConditions.values; } super.initBuilder(tx); } else { @@ -93,9 +100,9 @@ export abstract class AbstractContractBuilder extends TransactionBuilder { contractName: this._contractName, functionName: this._functionName, functionArgs: this._functionArgs, - postConditionMode: this._postConditionMode, + // v7 requires an explicit post-condition mode; v2 defaulted an unset mode to `Deny`. + postConditionMode: this._postConditionMode ?? PostConditionMode.Deny, postConditions: this._postConditions, - anchorMode: this._anchorMode, network: this._network, fee: new BigNum(this._fee.fee).toString(), nonce: new BigNum(this._nonce).toString(), diff --git a/modules/sdk-coin-stx/src/lib/constants.ts b/modules/sdk-coin-stx/src/lib/constants.ts index 0a75d7ddaae..3506768f6d1 100644 --- a/modules/sdk-coin-stx/src/lib/constants.ts +++ b/modules/sdk-coin-stx/src/lib/constants.ts @@ -42,6 +42,4 @@ export const VALID_CONTRACT_FUNCTION_NAMES = [ export const DEFAULT_SEED_SIZE_BYTES = 64; -// https://github.com/stacksgov/sips/blob/main/sips/sip-005/sip-005-blocks-and-transactions.md#transaction-encoding -export const ANCHOR_MODE = 3; export const DEFAULT_MULTISIG_SIG_NUMBER = 2; diff --git a/modules/sdk-coin-stx/src/lib/contractBuilder.ts b/modules/sdk-coin-stx/src/lib/contractBuilder.ts index 94ec7582d94..b160bd5362e 100644 --- a/modules/sdk-coin-stx/src/lib/contractBuilder.ts +++ b/modules/sdk-coin-stx/src/lib/contractBuilder.ts @@ -22,6 +22,14 @@ import { CONTRACT_NAME_SENDMANY, VALID_STAKING_CONTRACT_NAMES } from './constant import { AbstractContractBuilder } from './abstractContractBuilder'; export class ContractBuilder extends AbstractContractBuilder { + private static readonly CLARITY_TYPE_VALUES = new Set(Object.values(ClarityType)); + /** Clarity values that carry no `value` property in v7. */ + private static readonly VALUELESS_CLARITY_TYPES = new Set([ + ClarityType.OptionalNone, + ClarityType.BoolTrue, + ClarityType.BoolFalse, + ]); + constructor(_coinConfig: Readonly) { super(_coinConfig); } @@ -92,16 +100,35 @@ export class ContractBuilder extends AbstractContractBuilder { functionArgs(args: ClarityValueJson[] | ClarityValue[]): this { this._functionArgs = args.map((arg) => { - if (!ClarityType[arg.type]) { - return this.parseCv(arg); - } else { + if (this.isClarityValue(arg)) { // got direct clarity value after deserialization in fromImplementation return arg; } + return this.parseCv(arg); }); return this; } + /** + * Positive shape check: a fully-formed `ClarityValue` vs a JSON `ClarityValueJson` descriptor. + * + * In `@stacks/transactions` v7 the `ClarityType` values are lower-case strings (`'uint'`, + * `'contract'`, `'address'`, ...). A raw clarity value carries a `type` that is one of those values + * AND a `value` property (except the valueless `none` / `true` / `false`), and never a `val` field. + * JSON descriptors use `type` names (e.g. `optional`, `list`, `response`, `tuple`, `buffer`, + * `contractPrincipal`) and always carry `val` — a malformed descriptor like `{ type: 'list' }` + * therefore falls through to `parseCv` so its validation errors still surface. + */ + private isClarityValue(arg: ClarityValueJson | ClarityValue): arg is ClarityValue { + if (typeof arg.type !== 'string' || !ContractBuilder.CLARITY_TYPE_VALUES.has(arg.type)) { + return false; + } + if ('val' in arg) { + return false; + } + return 'value' in arg || ContractBuilder.VALUELESS_CLARITY_TYPES.has(arg.type); + } + private parseCv(arg: ClarityValueJson): ClarityValue { switch (arg.type) { case 'optional': diff --git a/modules/sdk-coin-stx/src/lib/fungibleTokenTransferBuilder.ts b/modules/sdk-coin-stx/src/lib/fungibleTokenTransferBuilder.ts index bf7c131f348..f699c023a79 100644 --- a/modules/sdk-coin-stx/src/lib/fungibleTokenTransferBuilder.ts +++ b/modules/sdk-coin-stx/src/lib/fungibleTokenTransferBuilder.ts @@ -5,8 +5,7 @@ import { AddressVersion, ClarityValue, ContractCallPayload, - FungibleConditionCode, - makeStandardFungiblePostCondition, + Pc, PostCondition, PostConditionMode, } from '@stacks/transactions'; @@ -141,25 +140,29 @@ export class FungibleTokenTransferBuilder extends AbstractContractBuilder { private tokenTransferParamsToPostCondition(tokenTransferParams: TokenTransferParams): PostCondition[] { if (!this._tokenName) { const contractTokenName = findContractTokenNameUsingContract(this._contractAddress, this._contractName); - if (contractTokenName) { - this.tokenName(contractTokenName); + if (!contractTokenName) { + // Fail the build rather than serialising an `undefined` asset name: such a post-condition can + // never match, so a `Deny`-mode tx would only abort on-chain and burn the fee. + throw new InvalidParameterValueError( + `unknown fungible token contract: ${this._contractAddress}.${this._contractName}` + ); } + this.tokenName(contractTokenName); } const amount: BigNum = new BigNum(tokenTransferParams.amount); return [ - makeStandardFungiblePostCondition( + Pc.principal( getSTXAddressFromPubKeys( this._fromPubKeys, this._coinConfig.network.type === NetworkType.MAINNET ? AddressVersion.MainnetMultiSig : AddressVersion.TestnetMultiSig, - this._fromPubKeys.length > 1 ? AddressHashMode.SerializeP2SH : AddressHashMode.SerializeP2PKH, + this._fromPubKeys.length > 1 ? AddressHashMode.P2SH : AddressHashMode.P2PKH, this._numberSignatures - ).address, - FungibleConditionCode.Equal, - amount.toString(), - `${this._contractAddress}.${this._contractName}::${this._tokenName}` - ), + ).address + ) + .willSendEq(amount.toString()) + .ft(`${this._contractAddress}.${this._contractName}`, this._tokenName), ]; } } diff --git a/modules/sdk-coin-stx/src/lib/index.ts b/modules/sdk-coin-stx/src/lib/index.ts index e40977f6929..5fd3056f8e2 100644 --- a/modules/sdk-coin-stx/src/lib/index.ts +++ b/modules/sdk-coin-stx/src/lib/index.ts @@ -7,4 +7,5 @@ export * from './sbtcWithdrawBuilder'; export * from './btcAddressUtils'; export * from './pox5Builder'; export * from './iface'; +export * from './postConditions'; export * as Utils from './utils'; diff --git a/modules/sdk-coin-stx/src/lib/keyPair.ts b/modules/sdk-coin-stx/src/lib/keyPair.ts index d4fb5343025..d9d429d23a5 100644 --- a/modules/sdk-coin-stx/src/lib/keyPair.ts +++ b/modules/sdk-coin-stx/src/lib/keyPair.ts @@ -1,6 +1,7 @@ import { randomBytes } from 'crypto'; import { bip32, ECPair } from '@bitgo/secp256k1'; -import { getAddressFromPublicKey, TransactionVersion } from '@stacks/transactions'; +import { getAddressFromPublicKey } from '@stacks/transactions'; +import { TransactionVersion } from '@stacks/network'; import { DefaultKeys, isPrivateKey, @@ -123,6 +124,9 @@ export class KeyPair extends Secp256k1ExtendedKeyPair { * @returns {string} The public address */ getSTXAddress(compressed = false, network: TransactionVersion = TransactionVersion.Mainnet): string { - return getAddressFromPublicKey(this.getKeys(compressed).pub, network); + // v7 `getAddressFromPublicKey` takes a network name; `TransactionVersion` only has Mainnet/Testnet, + // so anything other than Testnet maps to mainnet (there is no third value). + const networkName = network === TransactionVersion.Testnet ? 'testnet' : 'mainnet'; + return getAddressFromPublicKey(this.getKeys(compressed).pub, networkName); } } diff --git a/modules/sdk-coin-stx/src/lib/network.ts b/modules/sdk-coin-stx/src/lib/network.ts index 9396789504a..8fbbc482ec7 100644 --- a/modules/sdk-coin-stx/src/lib/network.ts +++ b/modules/sdk-coin-stx/src/lib/network.ts @@ -1,5 +1,5 @@ import { BaseCoin as CoinConfig, NetworkType, StacksNetwork as StaticsStacksNetwork } from '@bitgo/statics'; -import { StacksMainnet, StacksNetwork, StacksTestnet } from '@stacks/network'; +import { STACKS_MAINNET, STACKS_TESTNET, StacksNetwork } from '@stacks/network'; /** * Build the `@stacks/network` object a transaction is serialized against. @@ -15,13 +15,14 @@ import { StacksMainnet, StacksNetwork, StacksTestnet } from '@stacks/network'; */ export function toStacksNetwork(coinConfig: Readonly): StacksNetwork { const staticsNetwork = coinConfig.network as StaticsStacksNetwork; - const network = staticsNetwork.type === NetworkType.MAINNET ? new StacksMainnet() : new StacksTestnet(); + const stock = staticsNetwork.type === NetworkType.MAINNET ? STACKS_MAINNET : STACKS_TESTNET; + const network: StacksNetwork = { ...stock }; if (staticsNetwork.chainId !== undefined) { network.chainId = staticsNetwork.chainId; } if (staticsNetwork.transactionVersion !== undefined) { - network.version = staticsNetwork.transactionVersion; + network.transactionVersion = staticsNetwork.transactionVersion; } return network; diff --git a/modules/sdk-coin-stx/src/lib/postConditions.ts b/modules/sdk-coin-stx/src/lib/postConditions.ts new file mode 100644 index 00000000000..5b705a38c76 --- /dev/null +++ b/modules/sdk-coin-stx/src/lib/postConditions.ts @@ -0,0 +1,80 @@ +import { FungibleConditionCode, Pc, StakingPostCondition } from '@stacks/transactions'; + +/** + * Comparator accepted by {@link makeStakingPostCondition}: either the `FungibleConditionCode` enum + * from `@stacks/transactions` (`Equal`, `Greater`, `GreaterEqual`, `Less`, `LessEqual`) or the string + * form used by the v7 wire format (`'eq' | 'gt' | 'gte' | 'lt' | 'lte'`). + */ +export type PostConditionCode = FungibleConditionCode | 'eq' | 'gt' | 'gte' | 'lt' | 'lte'; + +/** Amount accepted by {@link makeStakingPostCondition} (micro-STX). */ +export type PostConditionAmount = string | number | bigint; + +type Comparator = 'eq' | 'gt' | 'gte' | 'lt' | 'lte'; + +function toComparator(code: PostConditionCode): Comparator { + if (typeof code === 'string') { + return code; + } + switch (code) { + case FungibleConditionCode.Equal: + return 'eq'; + case FungibleConditionCode.Greater: + return 'gt'; + case FungibleConditionCode.GreaterEqual: + return 'gte'; + case FungibleConditionCode.Less: + return 'lt'; + case FungibleConditionCode.LessEqual: + return 'lte'; + default: + throw new Error(`Unsupported fungible condition code: ${code}`); + } +} + +function applyComparator( + builder: ReturnType, + code: PostConditionCode, + amount: PostConditionAmount +): ReturnType['willSendEq']> { + switch (toComparator(code)) { + case 'eq': + return builder.willSendEq(amount); + case 'gt': + return builder.willSendGt(amount); + case 'gte': + return builder.willSendGte(amount); + case 'lt': + return builder.willSendLt(amount); + case 'lte': + return builder.willSendLte(amount); + } +} + +/** + * Build a SIP-044 **staking** post-condition (`PostConditionType.Staking = 3`). + * + * This is the post-condition required by PoX-5 `register-for-bond` (and any call that locks the + * staker's STX): the staked STX is tracked under the staking condition type, so an STX post-condition + * does **not** cover it and a `Deny`-mode tx with no/mismatched post-condition aborts on-chain with + * `… STX was staked by … but not checked` (SI-1936). + * + * Only `@stacks/transactions` v7 can construct it — the driver for the v7 migration (SI-2024). + * + * @param address the staker principal whose locked STX is asserted + * @param code the comparator (`'eq' | 'gt' | 'gte' | 'lt' | 'lte'` or `FungibleConditionCode`) + * @param amount the locked amount in micro-STX + * + * @example + * ```ts + * builder.postConditionMode(PostConditionMode.Deny); + * builder.postConditions([makeStakingPostCondition(staker, 'eq', amountUstx)]); + * ``` + */ +export function makeStakingPostCondition( + address: string, + code: PostConditionCode, + amount: PostConditionAmount +): StakingPostCondition { + return applyComparator(Pc.principal(address), code, amount).ustxToLock(); +} diff --git a/modules/sdk-coin-stx/src/lib/sbtcWithdrawBuilder.ts b/modules/sdk-coin-stx/src/lib/sbtcWithdrawBuilder.ts index 08f6dc56c44..766779ad4e7 100644 --- a/modules/sdk-coin-stx/src/lib/sbtcWithdrawBuilder.ts +++ b/modules/sdk-coin-stx/src/lib/sbtcWithdrawBuilder.ts @@ -6,9 +6,8 @@ import { AddressVersion, bufferCV, ClarityType, - createAssetInfo, - FungibleConditionCode, - makeStandardFungiblePostCondition, + ContractCallPayload, + Pc, PostCondition, PostConditionMode, tupleCV, @@ -19,7 +18,6 @@ import { Transaction } from './transaction'; import { getSTXAddressFromPubKeys, isValidAmount } from './utils'; import { SbtcWithdrawParams } from './iface'; import { CONTRACT_NAME_SBTC_WITHDRAWAL, FUNCTION_NAME_INITIATE_WITHDRAWAL } from './constants'; -import { ContractCallPayload } from '@stacks/transactions/dist/payload'; import { AbstractContractBuilder } from './abstractContractBuilder'; import { decodeBtcAddress, isValidBtcAddress } from './btcAddressUtils'; @@ -85,8 +83,8 @@ export class SbtcWithdrawBuilder extends AbstractContractBuilder { if (args[1].type !== ClarityType.Tuple) { throw new BuildTransactionError('Expected tuple for recipient argument'); } - const versionBuf = args[1].data['version']; - const hashbytesBuf = args[1].data['hashbytes']; + const versionBuf = args[1].value['version']; + const hashbytesBuf = args[1].value['hashbytes']; if (versionBuf?.type !== ClarityType.Buffer || hashbytesBuf?.type !== ClarityType.Buffer) { throw new BuildTransactionError('Expected buffer fields in recipient tuple'); } @@ -121,16 +119,16 @@ export class SbtcWithdrawBuilder extends AbstractContractBuilder { if (recipientTuple?.type !== ClarityType.Tuple) { return undefined; } - const versionBuf = recipientTuple.data['version']; - const hashbytesBuf = recipientTuple.data['hashbytes']; + const versionBuf = recipientTuple.value['version']; + const hashbytesBuf = recipientTuple.value['hashbytes']; if (versionBuf?.type !== ClarityType.Buffer || hashbytesBuf?.type !== ClarityType.Buffer) { return undefined; } return { amount: this._withdrawParams.amount, maxFee: this._withdrawParams.maxFee, - recipientVersion: versionBuf.buffer[0], - recipientHashBytes: Buffer.from(hashbytesBuf.buffer), + recipientVersion: Buffer.from(versionBuf.value, 'hex')[0], + recipientHashBytes: Buffer.from(hashbytesBuf.value, 'hex'), }; } @@ -174,19 +172,18 @@ export class SbtcWithdrawBuilder extends AbstractContractBuilder { const sbtcContractAddress = network.sbtcWithdrawalContractAddress; return [ - makeStandardFungiblePostCondition( + Pc.principal( getSTXAddressFromPubKeys( this._fromPubKeys, this._coinConfig.network.type === NetworkType.MAINNET ? AddressVersion.MainnetMultiSig : AddressVersion.TestnetMultiSig, - this._fromPubKeys.length > 1 ? AddressHashMode.SerializeP2SH : AddressHashMode.SerializeP2PKH, + this._fromPubKeys.length > 1 ? AddressHashMode.P2SH : AddressHashMode.P2PKH, this._numberSignatures - ).address, - FungibleConditionCode.Equal, - amount.toString(), - createAssetInfo(sbtcContractAddress, SBTC_TOKEN_CONTRACT_NAME, SBTC_TOKEN_ASSET_NAME) - ), + ).address + ) + .willSendEq(amount.toString()) + .ft(`${sbtcContractAddress}.${SBTC_TOKEN_CONTRACT_NAME}`, SBTC_TOKEN_ASSET_NAME), ]; } } diff --git a/modules/sdk-coin-stx/src/lib/sendmanyBuilder.ts b/modules/sdk-coin-stx/src/lib/sendmanyBuilder.ts index 02e6994b0d9..f01c7e376da 100644 --- a/modules/sdk-coin-stx/src/lib/sendmanyBuilder.ts +++ b/modules/sdk-coin-stx/src/lib/sendmanyBuilder.ts @@ -6,9 +6,9 @@ import { AddressVersion, bufferCVFromString, ClarityValue, - FungibleConditionCode, + ContractCallPayload, listCV, - makeStandardSTXPostCondition, + Pc, PostCondition, PostConditionMode, standardPrincipalCV, @@ -26,7 +26,6 @@ import { } from './utils'; import { SendParams } from './iface'; import { CONTRACT_NAME_SENDMANY, FUNCTION_NAME_SENDMANY } from './constants'; -import { ContractCallPayload } from '@stacks/transactions/dist/payload'; import { AbstractContractBuilder } from './abstractContractBuilder'; export class SendmanyBuilder extends AbstractContractBuilder { @@ -60,18 +59,18 @@ export class SendmanyBuilder extends AbstractContractBuilder { private sendParamsToPostcondition(sendParams: SendParams[]): PostCondition[] { const sum: BigNum = sendParams.reduce((current, next) => current.add(new BigNum(next.amount)), new BigNum(0)); return [ - makeStandardSTXPostCondition( + Pc.principal( getSTXAddressFromPubKeys( this._fromPubKeys, this._coinConfig.network.type === NetworkType.MAINNET ? AddressVersion.MainnetMultiSig : AddressVersion.TestnetMultiSig, - this._fromPubKeys.length > 1 ? AddressHashMode.SerializeP2SH : AddressHashMode.SerializeP2PKH, + this._fromPubKeys.length > 1 ? AddressHashMode.P2SH : AddressHashMode.P2PKH, this._numberSignatures - ).address, - FungibleConditionCode.Equal, - sum.toString() - ), + ).address + ) + .willSendEq(sum.toString()) + .ustx(), ]; } diff --git a/modules/sdk-coin-stx/src/lib/transaction.ts b/modules/sdk-coin-stx/src/lib/transaction.ts index 4185ce786a8..a30c73f4269 100644 --- a/modules/sdk-coin-stx/src/lib/transaction.ts +++ b/modules/sdk-coin-stx/src/lib/transaction.ts @@ -1,20 +1,21 @@ import { addressToString, - BufferReader, + BytesReader, ContractCallPayload, - createStacksPrivateKey, + createMessageSignature, createStacksPublicKey, createTransactionAuthField, cvToString, cvToValue, deserializeTransaction, + getFee, isSingleSig, MultiSigSpendingCondition, PayloadType, PubKeyEncoding, - StacksMessageType, - StacksTransaction, - TransactionSigner, + SingleSigSpendingCondition, + StacksTransactionWire, + StacksWireType, } from '@stacks/transactions'; import { BaseCoin as CoinConfig, StacksNetwork } from '@bitgo/statics'; @@ -37,7 +38,7 @@ import { KeyPair } from './keyPair'; import { FUNCTION_NAME_TRANSFER } from './constants'; export class Transaction extends BaseTransaction { - private _stxTransaction: StacksTransaction; + private _stxTransaction: StacksTransactionWire; protected _type: TransactionType; private _sigHash: string; @@ -52,25 +53,31 @@ export class Transaction extends BaseTransaction { async sign(keyPair: KeyPair[] | KeyPair, sigHash?: string): Promise { const keyPairs = keyPair instanceof Array ? keyPair : [keyPair]; - const signer = new TransactionSigner(this._stxTransaction); - signer.checkOversign = false; - signer.sigHash = sigHash ?? this._sigHash ?? this._stxTransaction.verifyBegin(); + let currentSigHash = sigHash ?? this._sigHash ?? this._stxTransaction.verifyBegin(); for (const kp of keyPairs) { const keys = kp.getKeys(kp.getCompressed()); if (!keys.prv) { throw new SigningError('Missing private key'); } - const privKey = createStacksPrivateKey(keys.prv); - signer.signOrigin(privKey); - this._sigHash = signer.sigHash; + // Sign directly on the transaction. The v7 `TransactionSigner` constructor rejects tx state + // where signature fields already reach `signaturesRequired`, which previously (v2) was only + // checked inside `signOrigin` and could be opted out of via `checkOversign`. + currentSigHash = this._stxTransaction.signNextOrigin(currentSigHash, keys.prv); + this._sigHash = currentSigHash; } } async appendOrigin(pubKeyString: string[] | string): Promise { const pubKeyStrings = pubKeyString instanceof Array ? pubKeyString : [pubKeyString]; - const signer: TransactionSigner = new TransactionSigner(this._stxTransaction); pubKeyStrings.forEach((pubKey) => { - signer.appendOrigin(createStacksPublicKey(pubKey)); + // Append the origin public key directly on the transaction. The v7 `TransactionSigner` + // constructor rejects tx state where signature fields already reach `signaturesRequired`, + // which is exactly the multisig append step after the required signatures are collected. + // The key is appended **as given** (v2 semantics): the wire serializer compresses the key + // bytes for an uncompressed-tagged field, so this keeps the auth field byte-identical to v2 + // (tag Uncompressed + 33-byte compressed key). The compressed normalisation needed to match + // signer keys on a rebuild happens in `transactionBuilder.initBuilder`. + this._stxTransaction.appendPubkey(createStacksPublicKey(pubKey)); }); } @@ -81,7 +88,9 @@ export class Transaction extends BaseTransaction { const signatures = signature instanceof Array ? signature : [signature]; if (!isMultiSig) { - this._stxTransaction = this._stxTransaction.createTxWithSignature(signatures[0].data); + (this._stxTransaction.auth.spendingCondition as SingleSigSpendingCondition).signature = createMessageSignature( + signatures[0].data + ); } else { const authFields = signatures.map((sig) => createTransactionAuthField(PubKeyEncoding.Compressed, sig)); (this._stxTransaction.auth.spendingCondition as MultiSigSpendingCondition).fields = ( @@ -100,7 +109,7 @@ export class Transaction extends BaseTransaction { } else { const signatures: string[] = []; this._stxTransaction.auth.spendingCondition.fields.forEach((field) => { - if (field.contents.type === StacksMessageType.MessageSignature) { + if (field.contents.type === StacksWireType.MessageSignature) { signatures.push(field.contents.data); } }); @@ -117,7 +126,7 @@ export class Transaction extends BaseTransaction { } const result: TxData = { id: this._stxTransaction.txid(), - fee: this._stxTransaction.auth.getFee().toString(10), + fee: getFee(this._stxTransaction.auth).toString(10), from: getTxSenderAddress(this._stxTransaction), nonce: this.getNonce(), payload: this.getPayloadData(), @@ -133,11 +142,7 @@ export class Transaction extends BaseTransaction { // result.payload.memo will be padded with \u0000 up to // MEMO_MAX_LENGTH_BYTES as defined in @stacks/transactions memo: unpadMemo(payload.memo.content), - to: addressToString({ - type: StacksMessageType.Address, - version: payload.recipient.address.version, - hash160: payload.recipient.address.hash160.toString(), - }), + to: payload.recipient.value, amount: payload.amount.toString(), }; return txPayload; @@ -163,21 +168,21 @@ export class Transaction extends BaseTransaction { * @returns {number} size in bytes of the serialized transaction */ transactionSize(): number { - return this._stxTransaction.serialize().length; + return this._stxTransaction.serializeBytes().length; } toBroadcastFormat(): string { if (!this._stxTransaction) { throw new ParseTransactionError('Empty transaction'); } - return this._stxTransaction.serialize().toString('hex'); + return this._stxTransaction.serialize(); } - get stxTransaction(): StacksTransaction { + get stxTransaction(): StacksTransactionWire { return this._stxTransaction; } - set stxTransaction(t: StacksTransaction) { + set stxTransaction(t: StacksTransactionWire) { this._stxTransaction = t; } @@ -197,7 +202,7 @@ export class Transaction extends BaseTransaction { fromRawTransaction(rawTransaction: string): void { const raw = removeHexPrefix(rawTransaction); try { - this._stxTransaction = deserializeTransaction(BufferReader.fromBuffer(Buffer.from(raw, 'hex'))); + this._stxTransaction = deserializeTransaction(new BytesReader(raw)); } catch (e) { throw new ParseTransactionError('Error parsing the raw transaction'); } diff --git a/modules/sdk-coin-stx/src/lib/transactionBuilder.ts b/modules/sdk-coin-stx/src/lib/transactionBuilder.ts index dd5f73357f9..5d42bc8a678 100644 --- a/modules/sdk-coin-stx/src/lib/transactionBuilder.ts +++ b/modules/sdk-coin-stx/src/lib/transactionBuilder.ts @@ -3,14 +3,15 @@ import BigNum from 'bn.js'; import { BaseCoin as CoinConfig } from '@bitgo/statics'; import { AuthType, - BufferReader, + BytesReader, + compressPublicKey, deserializeTransaction, emptyMessageSignature, isSingleSig, - makeSigHashPreSign, + sigHashPreSign, nextVerification, - publicKeyFromSignature, - StacksMessageType, + publicKeyFromSignatureVrs, + StacksWireType, PubKeyEncoding, } from '@stacks/transactions'; import { StacksNetwork } from '@stacks/network'; @@ -30,12 +31,11 @@ import { Transaction } from './transaction'; 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 { DEFAULT_MULTISIG_SIG_NUMBER } from './constants'; import { toStacksNetwork } from './network'; export abstract class TransactionBuilder extends BaseTransactionBuilder { private _transaction: Transaction; - protected _anchorMode: number; protected _fee: BaseFee; protected _nonce: number; protected _memo: string; @@ -47,7 +47,6 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder { constructor(_coinConfig: Readonly) { super(_coinConfig); - this._anchorMode = ANCHOR_MODE; this._multiSignerKeyPairs = []; this._fromPubKeys = []; this._signatures = []; @@ -77,19 +76,19 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder { this._numberSignatures = 1; if (tx.stxTransaction.auth.spendingCondition.signature.data !== emptyMessageSignature().data) { const signature = tx.stxTransaction.auth.spendingCondition.signature; - sigHash = makeSigHashPreSign( + sigHash = sigHashPreSign( sigHash, authType, new BigNum(this._fee.fee).toString(), new BigNum(this._nonce).toString() ); this._signatures.push({ ...signature, index: 0, sigHash }); - this._fromPubKeys = [publicKeyFromSignature(sigHash, signature)]; + this._fromPubKeys = [publicKeyFromSignatureVrs(sigHash, signature.data)]; } } else { this._numberSignatures = tx.stxTransaction.auth.spendingCondition.signaturesRequired; tx.stxTransaction.auth.spendingCondition.fields.forEach((field, index) => { - if (field.contents.type === StacksMessageType.MessageSignature) { + if (field.contents.type === StacksWireType.MessageSignature) { const signature = field.contents; const nextVerify = nextVerification( sigHash, @@ -97,13 +96,16 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder { new BigNum(this._fee.fee).toString(), new BigNum(this._nonce).toString(), PubKeyEncoding.Compressed, // useless param as Compressed is hardcoded in stacks lib - signature + signature.data ); sigHash = nextVerify.nextSigHash; this._signatures.push({ ...signature, index, sigHash }); - this._fromPubKeys.push(nextVerify.pubKey.data.toString('hex')); + this._fromPubKeys.push(Buffer.from(nextVerify.pubKey.data).toString('hex')); } else { - this._fromPubKeys.push(field.contents.data.toString('hex')); + // v7 deserializes an uncompressed-tagged pubkey field back to the 65-byte key; normalise + // to compressed so it matches the compressed signer keys (from signatures / `sign()`) and + // the append step, preventing duplicate entries on a rebuild. + this._fromPubKeys.push(compressPublicKey(field.contents.data)); } }); } @@ -113,9 +115,7 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder { protected fromImplementation(rawTransaction: string): Transaction { const tx = new Transaction(this._coinConfig); this.validateRawTransaction(rawTransaction); - const stackstransaction = deserializeTransaction( - BufferReader.fromBuffer(Buffer.from(removeHexPrefix(rawTransaction), 'hex')) - ); + const stackstransaction = deserializeTransaction(new BytesReader(removeHexPrefix(rawTransaction))); tx.stxTransaction = stackstransaction; this.initBuilder(tx); return this.transaction; @@ -125,6 +125,14 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder { /** @inheritdoc */ protected async buildImplementation(): Promise { const isMultiSig: boolean = this._fromPubKeys.length > 1; + // `@stacks/transactions` v7 pre-populates a multisig spending condition with the public keys + // passed to `makeUnsigned*`. v2 left the field list empty and this builder re-derives it below + // (one auth field per signer: a signature when available, otherwise the public key). Clear the + // pre-populated keys so the serialized auth fields stay byte-identical to the previous behaviour. + const spendingCondition = this._transaction.stxTransaction?.auth?.spendingCondition; + if (spendingCondition && !isSingleSig(spendingCondition)) { + spendingCondition.fields = []; + } this._transaction.stxTransaction.setFee(new BigNum(this._fee.fee).toString()); this._transaction.stxTransaction.setNonce(new BigNum(this._nonce).toString()); @@ -273,7 +281,7 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder { throw new InvalidTransactionError('Raw transaction is empty'); } try { - deserializeTransaction(BufferReader.fromBuffer(Buffer.from(removeHexPrefix(rawTransaction), 'hex'))); + deserializeTransaction(new BytesReader(removeHexPrefix(rawTransaction))); } catch (e) { throw new ParseTransactionError('There was an error parsing the raw transaction'); } diff --git a/modules/sdk-coin-stx/src/lib/transactionBuilderFactory.ts b/modules/sdk-coin-stx/src/lib/transactionBuilderFactory.ts index a73752f907e..96716a24713 100644 --- a/modules/sdk-coin-stx/src/lib/transactionBuilderFactory.ts +++ b/modules/sdk-coin-stx/src/lib/transactionBuilderFactory.ts @@ -1,5 +1,5 @@ import { BaseCoin as CoinConfig } from '@bitgo/statics'; -import { BufferReader, deserializeTransaction, PayloadType } from '@stacks/transactions'; +import { BytesReader, deserializeTransaction, PayloadType } from '@stacks/transactions'; import { BaseTransactionBuilderFactory, InvalidTransactionError, @@ -54,9 +54,7 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { private parseTransaction(rawTransaction: string): Transaction { const tx = new Transaction(this._coinConfig); - const stackstransaction = deserializeTransaction( - BufferReader.fromBuffer(Buffer.from(Utils.removeHexPrefix(rawTransaction), 'hex')) - ); + const stackstransaction = deserializeTransaction(new BytesReader(Utils.removeHexPrefix(rawTransaction))); tx.stxTransaction = stackstransaction; return tx; } diff --git a/modules/sdk-coin-stx/src/lib/transferBuilder.ts b/modules/sdk-coin-stx/src/lib/transferBuilder.ts index 79061685cb0..700fd974132 100644 --- a/modules/sdk-coin-stx/src/lib/transferBuilder.ts +++ b/modules/sdk-coin-stx/src/lib/transferBuilder.ts @@ -56,7 +56,6 @@ export class TransferBuilder extends TransactionBuilder { recipient: this._toAddress, amount: this._amount.toString(), memo: this._memo, - anchorMode: this._anchorMode, network: this._network, fee: new BigNum(this._fee.fee).toString(), nonce: new BigNum(this._nonce).toString(), diff --git a/modules/sdk-coin-stx/src/lib/utils.ts b/modules/sdk-coin-stx/src/lib/utils.ts index c29ed79ac3a..9a108ef7c73 100644 --- a/modules/sdk-coin-stx/src/lib/utils.ts +++ b/modules/sdk-coin-stx/src/lib/utils.ts @@ -8,27 +8,25 @@ import { addressHashModeToVersion, addressToString, AddressVersion, - BufferReader, + BytesReader, ClarityType, ClarityValue, contractPrincipalCV, createAddress, createMemoString, - createMessageSignature, - createStacksPrivateKey, createStacksPublicKey, cvToString, cvToValue, deserializeTransaction, PubKeyEncoding, - publicKeyFromSignature, + publicKeyFromSignatureVrs, serializeCV, signWithKey, - StacksTransaction, + StacksTransactionWire, standardPrincipalCV, - TransactionVersion, validateStacksAddress, } from '@stacks/transactions'; +import { StacksNetworkName, TransactionVersion } from '@stacks/network'; import { secp256k1 } from '@noble/curves/secp256k1'; import * as _ from 'lodash'; import { @@ -91,6 +89,10 @@ export function removeHexPrefix(hex: string): string { return stripHexPrefix(hex); } +function transactionVersionToNetworkName(transactionVersion: TransactionVersion): StacksNetworkName { + return transactionVersion === TransactionVersion.Testnet ? 'testnet' : 'mainnet'; +} + /** * Get stacks address from public key hash * @@ -108,7 +110,7 @@ function getAddressFromPublicKeyHash( throw new Error('expected 20-byte pubkeyhash'); } - const addrVer = addressHashModeToVersion(hashMode, transactionVersion); + const addrVer = addressHashModeToVersion(hashMode, transactionVersionToNetworkName(transactionVersion)); const addr = addressFromVersionHash(addrVer, publicKeyHash.toString('hex')); const addrString = addressToString(addr); return addrString; @@ -117,13 +119,13 @@ function getAddressFromPublicKeyHash( /** * @param tx */ -export function getTxSenderAddress(tx: StacksTransaction): string { +export function getTxSenderAddress(tx: StacksTransactionWire): string { if (tx.auth.spendingCondition !== null && tx.auth.spendingCondition !== undefined) { const spendingCondition = tx.auth.spendingCondition; const txSender = getAddressFromPublicKeyHash( Buffer.from(spendingCondition.signer, 'hex'), spendingCondition.hashMode as number, - tx.version + tx.transactionVersion ); return txSender; } else throw new Error('spendingCondition should not be null'); @@ -242,7 +244,7 @@ function allHexChars(maybe: string): boolean { export function isValidRawTransaction(rawTransaction: unknown): boolean { try { if (typeof rawTransaction === 'string') { - deserializeTransaction(BufferReader.fromBuffer(Buffer.from(removeHexPrefix(rawTransaction), 'hex'))); + deserializeTransaction(new BytesReader(removeHexPrefix(rawTransaction))); } else { return false; } @@ -260,6 +262,12 @@ export function isValidRawTransaction(rawTransaction: unknown): boolean { * @returns {boolean} - the validation result */ export function isValidMemo(memo: string): boolean { + // v7's `createMemoString` only guards byte length via `exceedsMaxLengthBytes`, which (unlike + // v2's `Buffer.from(string)`) no longer throws on non-strings — it silently coerces them. + // Reject non-strings explicitly so a non-string memo stays invalid, matching v2 behaviour. + if (typeof memo !== 'string') { + return false; + } try { createMemoString(memo); } catch (e) { @@ -326,7 +334,7 @@ export function unpadMemo(memo: string): string { export function getSTXAddressFromPubKeys( pubKeys: string[], addressVersion: AddressVersion = AddressVersion.MainnetMultiSig, - addressHashMode: AddressHashMode = AddressHashMode.SerializeP2SH, + addressHashMode: AddressHashMode = AddressHashMode.P2SH, signaturesRequired = 2 ): { address: string; hash160: string } { if (pubKeys.length === 0) { @@ -355,7 +363,7 @@ export function getSTXAddressFromPubKeys( export function signMessage(keyPair: KeyPair, data: string): string { const prv = keyPair.getKeys().prv; if (prv) { - return signWithKey(createStacksPrivateKey(prv), Buffer.from(data).toString('hex')).data; + return signWithKey(prv, Buffer.from(data).toString('hex')); } else { throw new SigningError('Missing private key'); } @@ -381,9 +389,7 @@ export function verifySignature(message: string, signature: string, publicKey: s // provided publicKey can be compressed or uncompressed const keyEncoding = publicKey.length === 66 ? PubKeyEncoding.Compressed : PubKeyEncoding.Uncompressed; - const messageSig = createMessageSignature(signature); - - const foundKey = publicKeyFromSignature(Buffer.from(message).toString('hex'), messageSig, keyEncoding); + const foundKey = publicKeyFromSignatureVrs(Buffer.from(message).toString('hex'), signature, keyEncoding); return foundKey === publicKey; } @@ -482,12 +488,12 @@ export function stringifyCv(cv: ClarityValue): any { case ClarityType.Tuple: return { type: cv.type, - data: _.mapValues(cv.data, (value) => stringifyCv(value)), + data: _.mapValues(cv.value, (value) => stringifyCv(value)), }; case ClarityType.List: return { type: cv.type, - list: cv.list.map(stringifyCv), + list: cv.value.map(stringifyCv), }; default: return cv; @@ -504,19 +510,19 @@ export function functionArgsToSendParams(args: ClarityValue[]): SendParams[] { if (args.length !== 1 || args[0].type !== ClarityType.List) { throw new InvalidTransactionError("function args don't match send-many-memo type declaration"); } - return args[0].list.map((tuple) => { + return args[0].value.map((tuple) => { if ( tuple.type !== ClarityType.Tuple || - tuple.data.to?.type !== ClarityType.PrincipalStandard || - tuple.data.ustx?.type !== ClarityType.UInt || - tuple.data.memo?.type !== ClarityType.Buffer + tuple.value.to?.type !== ClarityType.PrincipalStandard || + tuple.value.ustx?.type !== ClarityType.UInt || + tuple.value.memo?.type !== ClarityType.Buffer ) { throw new InvalidTransactionError("function args don't match send-many-memo type declaration"); } return { - address: cvToString(tuple.data.to), - amount: cvToValue(tuple.data.ustx, true), - memo: tuple.data.memo.buffer.toString('ascii'), + address: cvToString(tuple.value.to), + amount: cvToValue(tuple.value.ustx, true), + memo: Buffer.from(tuple.value.memo.value, 'hex').toString('ascii'), }; }); } @@ -538,7 +544,7 @@ export function functionArgsToTokenTransferParams(args: ClarityValue[]): TokenTr recipient: cvToString(args[2]), }; if (args.length === 4 && args[3].type === ClarityType.Buffer) { - tokenTransferParams['memo'] = args[3].buffer.toString('ascii'); + tokenTransferParams['memo'] = Buffer.from(args[3].value, 'hex').toString('ascii'); } return tokenTransferParams; } @@ -578,7 +584,7 @@ export function getEncodedPrincipal(principal: string): string { if (!isValidAddress(principal)) { throw new UtilsError(`invalid Stacks address in principal: ${principal}`); } - return serializeCV(standardPrincipalCV(principal)).toString('hex'); + return serializeCV(standardPrincipalCV(principal)); } /** @@ -628,7 +634,7 @@ export function findTokenNameByContract(contractAddress: string, contractName: s const tokenName = coins .filter((coin) => coin instanceof Sip10Token && coin.assetId.includes(`${contractAddress}.${contractName}`)) .map((coin) => coin.name); - return tokenName ? tokenName[0] : undefined; + return tokenName.length > 0 ? tokenName[0] : undefined; } } @@ -644,7 +650,7 @@ export function findContractTokenNameUsingContract(contractAddress: string, cont const sip10Token = coins .filter((coin) => coin instanceof Sip10Token && coin.assetId.includes(`${contractAddress}.${contractName}`)) .map((coin) => coin as Sip10Token); - return sip10Token ? sip10Token[0].assetId.split('::')[1] : undefined; + return sip10Token.length > 0 ? sip10Token[0].assetId.split('::')[1] : undefined; } } diff --git a/modules/sdk-coin-stx/src/stx.ts b/modules/sdk-coin-stx/src/stx.ts index 12800815d67..d8131c75445 100644 --- a/modules/sdk-coin-stx/src/stx.ts +++ b/modules/sdk-coin-stx/src/stx.ts @@ -24,19 +24,17 @@ import { bufferCVFromString, ClarityType, ClarityValue, - createStacksPrivateKey, cvToString, cvToValue, deserializeTransaction, noneCV, - privateKeyToString, - publicKeyFromBuffer, - publicKeyToString, + privateKeyToHex, + publicKeyToHex, + serializePayload, someCV, standardPrincipalCV, uintCV, } from '@stacks/transactions'; -import { serializePayload } from '@stacks/transactions/dist/payload'; import BigNumber from 'bignumber.js'; import { ExplainTransactionOptions, StxSignTransactionOptions, StxTransactionExplanation } from './types'; @@ -347,7 +345,7 @@ export class Stx extends BaseCoin { txJson.payload.functionArgs.length === 4 && txJson.payload.functionArgs[3].type === ClarityType.OptionalSome ) { - memo = Buffer.from(txJson.payload.functionArgs[3].value.buffer).toString(); + memo = Buffer.from(txJson.payload.functionArgs[3].value.value, 'hex').toString(); transactionRecipient['memo'] = memo; } } else { @@ -655,7 +653,7 @@ export class Stx extends BaseCoin { const baseTxn = await txBuilder.build(); const txBroadcastFormat = baseTxn.toBroadcastFormat(); const txDeserialized = deserializeTransaction(txBroadcastFormat); - const serializedHex = serializePayload(txDeserialized.payload).toString('hex'); + const serializedHex = serializePayload(txDeserialized.payload); const { recoverableAmount, fee } = await this.getRecoverableAmountAndFee( serializedHex, txBroadcastFormat.length, @@ -703,7 +701,7 @@ export class Stx extends BaseCoin { const baseTxn = await txBuilder.build(); const txBroadcastFormat = baseTxn.toBroadcastFormat(); const txDeserialized = deserializeTransaction(txBroadcastFormat); - const serializedHex = serializePayload(txDeserialized.payload).toString('hex'); + const serializedHex = serializePayload(txDeserialized.payload); const { recoverableAmount, fee } = await this.getRecoverableAmountAndFee( serializedHex, txBroadcastFormat.length, @@ -809,10 +807,11 @@ export class Stx extends BaseCoin { if (!balance || isNaN(balance)) { throw new ErrorNoInputToRecover('could not find any balance to recover for ' + params.rootAddress); } - const userPub = publicKeyFromBuffer(keys[0].publicKey); - const backupPub = publicKeyFromBuffer(keys[1].publicKey); - const bitgoPubKey = publicKeyFromBuffer(keys[2].publicKey); - const pubs = [publicKeyToString(userPub), publicKeyToString(backupPub), publicKeyToString(bitgoPubKey)]; + const pubs = [ + publicKeyToHex(keys[0].publicKey), + publicKeyToHex(keys[1].publicKey), + publicKeyToHex(keys[2].publicKey), + ]; const destinationAddressDetails = getAddressDetails(params.recoveryDestination); const nonce = @@ -835,8 +834,8 @@ export class Stx extends BaseCoin { if (!keys[0].privateKey) { throw new Error(`userKey is not a private key`); } - const userKey = createStacksPrivateKey(keys[0].privateKey); - builder.sign({ key: privateKeyToString(userKey) }); + const userKey = privateKeyToHex(keys[0].privateKey); + builder.sign({ key: userKey }); const halfSignedTx = await builder.build(); const txHexHalfSigned = halfSignedTx.toBroadcastFormat(); @@ -846,8 +845,8 @@ export class Stx extends BaseCoin { if (!keys[1].privateKey) { throw new Error(`backupKey is not a private key`); } - const backupKey = createStacksPrivateKey(keys[1].privateKey); - builder2.sign({ key: privateKeyToString(backupKey) }); + const backupKey = privateKeyToHex(keys[1].privateKey); + builder2.sign({ key: backupKey }); const fullySignedTx = await builder2.build(); const fullySignedTxHex = fullySignedTx.toBroadcastFormat(); diff --git a/modules/sdk-coin-stx/test/fixtures.ts b/modules/sdk-coin-stx/test/fixtures.ts index 9c261b56e99..48f39ea7422 100644 --- a/modules/sdk-coin-stx/test/fixtures.ts +++ b/modules/sdk-coin-stx/test/fixtures.ts @@ -1,4 +1,5 @@ import { ITransactionRecipient } from '@bitgo/sdk-core'; +import { BufferCV, ClarityType, StandardPrincipalCV, UIntCV } from '@stacks/transactions'; export const txForExplainTransfer = '0x80800000000400164247d6f2b425ac5771423ae6c80c754f7172b0000000000000000000000000000000b40000ab3fcf8d6d697d01c3da3a20b06cd8b36f016755cb7637c3eb6b228980d8857c7826a8ea498e52fd73ecd8e3869881c9aba49ae598dfbf1d5af69b1c02b4f2bd03020000000000051a1ae3f911d8f1d46d7416bfbe4b593fd41eac19cb00000000000003e85468697320697320616e206578616d706c6500000000000000000000000000000000'; @@ -20,7 +21,7 @@ export const txExplainedContract = { contractAddress: 'ST000000000000000000002AMW42H', contractName: 'pox-4', functionName: 'stack-stx', - functionArgs: [{ type: 1, value: '400000000' }], + functionArgs: [{ type: ClarityType.UInt, value: '400000000' }], }; export const unsignedTxForExplainTransfer = @@ -50,25 +51,11 @@ export const fungibleTokenTransferTx = { tokenName: 'tstx:tsip6dp', functionName: 'transfer', functionArgs: [ - { - type: 5, - address: { - type: 0, - version: 26, - hash160: '1500a1c42f0c11bfe3893f479af18904677685be', - }, - }, - { - type: 5, - address: { - type: 0, - version: 21, - hash160: 'ab50cac953ac55edc14e2b236854b1ead863fece', - }, - }, - { type: 1, value: '10000' }, - { type: 2 }, - ], + { type: ClarityType.PrincipalStandard, value: 'STAG18E45W613FZ3H4ZMF6QHH426EXM5QTSAVWYH' }, + { type: ClarityType.PrincipalStandard, value: 'SN2NN1JP9AEP5BVE19RNJ6T2MP7NDGRZYST1VDF3M' }, + { type: ClarityType.UInt, value: '10000' }, + { type: ClarityType.Buffer, value: '' }, + ] as [StandardPrincipalCV, StandardPrincipalCV, UIntCV, BufferCV], }; export const recipients: ITransactionRecipient[] = [ diff --git a/modules/sdk-coin-stx/test/unit/keyPair.ts b/modules/sdk-coin-stx/test/unit/keyPair.ts index c93d8da5576..ae6968e066b 100644 --- a/modules/sdk-coin-stx/test/unit/keyPair.ts +++ b/modules/sdk-coin-stx/test/unit/keyPair.ts @@ -3,7 +3,7 @@ import should from 'should'; import * as testData from './resources'; import { StxLib } from '../../src'; -import { TransactionVersion } from '@stacks/transactions'; +import { TransactionVersion } from '@stacks/network'; describe('Stx KeyPair', function () { const defaultSeed = { seed: Buffer.alloc(64) }; diff --git a/modules/sdk-coin-stx/test/unit/network.ts b/modules/sdk-coin-stx/test/unit/network.ts index d0e98c6198c..84b2bedf28b 100644 --- a/modules/sdk-coin-stx/test/unit/network.ts +++ b/modules/sdk-coin-stx/test/unit/network.ts @@ -1,7 +1,7 @@ import assert from 'assert'; import { BaseCoin as CoinConfig, coins } from '@bitgo/statics'; -import { pubKeyfromPrivKey, publicKeyToString } from '@stacks/transactions'; +import { privateKeyToPublic, publicKeyToHex } from '@stacks/transactions'; import { StxLib } from '../../src'; @@ -11,19 +11,19 @@ describe('Stacks network plumbing', function () { it('keeps the stock mainnet chain identity for stx', function () { const network = toStacksNetwork(coins.get('stx')); assert.strictEqual(network.chainId, 1); - assert.strictEqual(network.version, 0); + assert.strictEqual(network.transactionVersion, 0); }); it('keeps the stock testnet chain identity for tstx', function () { const network = toStacksNetwork(coins.get('tstx')); assert.strictEqual(network.chainId, 2147483648); - assert.strictEqual(network.version, 128); + assert.strictEqual(network.transactionVersion, 128); }); it('applies the tstxsignet chain identity declared in statics', function () { const network = toStacksNetwork(coins.get('tstxsignet')); assert.strictEqual(network.chainId, 1280); - assert.strictEqual(network.version, 0x80); + assert.strictEqual(network.transactionVersion, 0x80); }); // staking-testnet rejects transactions carrying the public testnet chain ID with @@ -41,8 +41,8 @@ describe('Stacks network plumbing', function () { }); async function buildTransfer(config: Readonly): Promise { - const publicKey = publicKeyToString( - pubKeyfromPrivKey('0f4fad1041051740108cdf523c346199e99d80cc845a4ea8d4d29b1a81fd22fc01') + const publicKey = publicKeyToHex( + privateKeyToPublic('0f4fad1041051740108cdf523c346199e99d80cc845a4ea8d4d29b1a81fd22fc01') ); const builder = new TransactionBuilderFactory(config).getTransferBuilder(); builder.to('STHT87WKW17VGMPFFNB6QD1ABNKGB1600ZE7YVW4'); diff --git a/modules/sdk-coin-stx/test/unit/postConditions.ts b/modules/sdk-coin-stx/test/unit/postConditions.ts new file mode 100644 index 00000000000..83cf9608875 --- /dev/null +++ b/modules/sdk-coin-stx/test/unit/postConditions.ts @@ -0,0 +1,64 @@ +import should from 'should'; +import { FungibleConditionCode } from '@stacks/transactions'; + +import { StxLib } from '../../src'; + +const { makeStakingPostCondition } = StxLib; + +describe('Stx post-condition helpers', function () { + const address = 'STC6GQPVW3GMP5AY5QHKDR9R9S82DDWRFAZA6SWA'; + + const expected = (condition: string, amount: string) => ({ + type: 'staking-postcondition', + address, + condition, + amount, + }); + + describe('makeStakingPostCondition', function () { + it('builds a SIP-044 staking post-condition (PostConditionType.Staking)', function () { + should.deepEqual(makeStakingPostCondition(address, 'eq', '100000000'), expected('eq', '100000000')); + }); + + const stringComparators: [Parameters[1], string][] = [ + ['eq', 'eq'], + ['gt', 'gt'], + ['gte', 'gte'], + ['lt', 'lt'], + ['lte', 'lte'], + ]; + + for (const [code, condition] of stringComparators) { + it(`accepts the string comparator '${code}'`, function () { + should.deepEqual(makeStakingPostCondition(address, code, '100000000'), expected(condition, '100000000')); + }); + } + + const enumComparators: [FungibleConditionCode, string][] = [ + [FungibleConditionCode.Equal, 'eq'], + [FungibleConditionCode.Greater, 'gt'], + [FungibleConditionCode.GreaterEqual, 'gte'], + [FungibleConditionCode.Less, 'lt'], + [FungibleConditionCode.LessEqual, 'lte'], + ]; + + for (const [code, condition] of enumComparators) { + it(`maps FungibleConditionCode(${code}) to the '${condition}' comparator`, function () { + should.deepEqual(makeStakingPostCondition(address, code, 100), expected(condition, '100')); + }); + } + + it('accepts a string, number and bigint amount', function () { + should.equal(makeStakingPostCondition(address, 'eq', '100').amount, '100'); + should.equal(makeStakingPostCondition(address, 'eq', 100).amount, '100'); + should.equal(makeStakingPostCondition(address, 'eq', 100n).amount, '100'); + }); + + it('rejects an unsupported condition code', function () { + should.throws( + () => makeStakingPostCondition(address, 99 as unknown as FungibleConditionCode, 100), + /Unsupported fungible condition code: 99/ + ); + }); + }); +}); diff --git a/modules/sdk-coin-stx/test/unit/resources.ts b/modules/sdk-coin-stx/test/unit/resources.ts index a92223373c7..b09ee59da4c 100644 --- a/modules/sdk-coin-stx/test/unit/resources.ts +++ b/modules/sdk-coin-stx/test/unit/resources.ts @@ -6,7 +6,7 @@ import { StxLib } from '../../src'; * import * as st from '@stacks/transactions'; * * const secretKey1 = st.privateKeyToString(st.makeRandomPrivKey()); - * const publicKey1 = st.publicKeyToString(st.pubKeyfromPrivKey(secretKey1.data); + * const publicKey1 = st.publicKeyToHex(st.privateKeyToPublic(secretKey1.data); * const address1 = st.getAddressFromPrivateKey(secretKey1); * etc. */ diff --git a/modules/sdk-coin-stx/test/unit/stx.ts b/modules/sdk-coin-stx/test/unit/stx.ts index 96545663537..9a22e92b881 100644 --- a/modules/sdk-coin-stx/test/unit/stx.ts +++ b/modules/sdk-coin-stx/test/unit/stx.ts @@ -5,7 +5,7 @@ import { BitGoAPI } from '@bitgo/sdk-api'; import { ErrorNoInputToRecover, RecoveryProviderError, Wallet } from '@bitgo/sdk-core'; import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test'; import { coins } from '@bitgo/statics'; -import { cvToString, deserializeCV, pubKeyfromPrivKey, publicKeyToString } from '@stacks/transactions'; +import { cvToString, deserializeCV, privateKeyToPublic, publicKeyToHex } from '@stacks/transactions'; import * as testData from '../fixtures'; import * as resources from './resources'; @@ -466,7 +466,7 @@ describe('STX:', function () { const builder = factory.getSbtcWithdrawBuilder(); builder.fee({ fee: '1000' }); builder.nonce(1); - const pubKeys = prvKeysString.map((prv) => publicKeyToString(pubKeyfromPrivKey(prv))); + const pubKeys = prvKeysString.map((prv) => publicKeyToHex(privateKeyToPublic(prv))); builder.fromPubKey(pubKeys); builder.numberSignatures(2); builder.withdraw(withdrawParams); diff --git a/modules/sdk-coin-stx/test/unit/transactionBuilder/contractBuilder.ts b/modules/sdk-coin-stx/test/unit/transactionBuilder/contractBuilder.ts index 5f5df40c68a..f59d4e50add 100644 --- a/modules/sdk-coin-stx/test/unit/transactionBuilder/contractBuilder.ts +++ b/modules/sdk-coin-stx/test/unit/transactionBuilder/contractBuilder.ts @@ -1,8 +1,8 @@ import assert from 'assert'; import should from 'should'; import BigNum from 'bn.js'; -import { StacksTestnet, StacksMainnet } from '@stacks/network'; -import { TransactionType } from '@bitgo/sdk-core'; +import { STACKS_TESTNET, STACKS_MAINNET } from '@stacks/network'; +import { InvalidParameterValueError, TransactionType } from '@bitgo/sdk-core'; import { bufferCV, noneCV, someCV, standardPrincipalCV, tupleCV, uintCV, intCV } from '@stacks/transactions'; import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test'; import { BitGoAPI } from '@bitgo/sdk-api'; @@ -49,8 +49,8 @@ describe('Stacks: Contract Builder', function () { const txBuilder: any = factory.getTransferBuilder(); const txBuilderProd: any = factoryProd.getTransferBuilder(); - txBuilder._network.should.deepEqual(new StacksTestnet()); - txBuilderProd._network.should.deepEqual(new StacksMainnet()); + txBuilder._network.should.deepEqual(STACKS_TESTNET); + txBuilderProd._network.should.deepEqual(STACKS_MAINNET); }); }); @@ -360,6 +360,15 @@ describe('Stacks: Contract Builder', function () { ]); }); + it('rejects a malformed JSON descriptor instead of treating it as a ClarityValue', () => { + const builder = initTxBuilder(); + // `{ type: 'list' }` / `{ type: 'tuple' }` reuse a ClarityType name but carry no + // `val`/`value`; they must still be validated by `parseCv` (and rejected) rather than + // passed through as raw ClarityValues. + assert.throws(() => builder.functionArgs([{ type: 'list' } as never]), InvalidParameterValueError); + assert.throws(() => builder.functionArgs([{ type: 'tuple' } as never]), InvalidParameterValueError); + }); + it('use ClarityValue', () => { const amount = '400000000'; const builder = initTxBuilder(); diff --git a/modules/sdk-coin-stx/test/unit/transactionBuilder/fungibleTokenTransferBuilder.ts b/modules/sdk-coin-stx/test/unit/transactionBuilder/fungibleTokenTransferBuilder.ts index 8370c46eb22..f45fff269a1 100644 --- a/modules/sdk-coin-stx/test/unit/transactionBuilder/fungibleTokenTransferBuilder.ts +++ b/modules/sdk-coin-stx/test/unit/transactionBuilder/fungibleTokenTransferBuilder.ts @@ -3,7 +3,7 @@ import { BitGoAPI } from '@bitgo/sdk-api'; import { coins } from '@bitgo/statics'; import { bufferCVFromString, noneCV, someCV, standardPrincipalCV, uintCV } from '@stacks/transactions'; import should from 'should'; -import { StacksMainnet, StacksTestnet } from '@stacks/network'; +import { STACKS_MAINNET, STACKS_TESTNET } from '@stacks/network'; import { TransactionType } from '@bitgo/sdk-core'; import assert from 'assert'; @@ -47,8 +47,8 @@ describe('Stacks: Fungible Token Transfer Builder', () => { const txBuilder: any = factory.getFungibleTokenTransferBuilder(); const txBuilderProd: any = factoryProd.getFungibleTokenTransferBuilder(); - txBuilder._network.should.deepEqual(new StacksTestnet()); - txBuilderProd._network.should.deepEqual(new StacksMainnet()); + txBuilder._network.should.deepEqual(STACKS_TESTNET); + txBuilderProd._network.should.deepEqual(STACKS_MAINNET); }); }); diff --git a/modules/sdk-coin-stx/test/unit/transactionBuilder/pox5Builder.ts b/modules/sdk-coin-stx/test/unit/transactionBuilder/pox5Builder.ts index 8cda4695ce7..da45869727d 100644 --- a/modules/sdk-coin-stx/test/unit/transactionBuilder/pox5Builder.ts +++ b/modules/sdk-coin-stx/test/unit/transactionBuilder/pox5Builder.ts @@ -1,14 +1,15 @@ import assert from 'assert'; import { ClarityType, + ClarityValue, ContractCallPayload, createAddress, cvToString, cvToValue, deserializeCV, - FungibleConditionCode, - makeStandardSTXPostCondition, + Pc, PostConditionMode, + PostConditionType, } from '@stacks/transactions'; import { coins } from '@bitgo/statics'; import should from 'should'; @@ -122,11 +123,7 @@ describe('Stacks: PoX-5 Builder', function () { const functionArgs = pox5RegisterForBondArgsHex.map((hex) => deserializeCV(Buffer.from(hex.replace(/^0x/, ''), 'hex')) ); - const postCondition = makeStandardSTXPostCondition( - testData.TX_SENDER.address, - FungibleConditionCode.LessEqual, - 100000000 - ); + const postCondition = Pc.principal(testData.TX_SENDER.address).willSendLte(100000000).ustx(); const builder = configure(factory.getPox5Builder()); builder.functionName('register-for-bond'); builder.functionArgs(functionArgs); @@ -146,6 +143,39 @@ describe('Stacks: PoX-5 Builder', function () { should.equal(rebuiltTx.stxTransaction.postConditions.values.length, 1); }); + it('register-for-bond builds Deny + a type-3 staking post-condition, byte-identical across from(hex) (SI-1936)', async () => { + const functionArgs = pox5RegisterForBondArgsHex.map((hex) => + deserializeCV(Buffer.from(hex.replace(/^0x/, ''), 'hex')) + ); + // SIP-044 staking post-condition on the register amount (contract arg index 2 = amount-ustx). + const amountUstx = cvToValue(functionArgs[2], true).toString(); + const stakingPostCondition = StxLib.makeStakingPostCondition(testData.TX_SENDER.address, 'eq', amountUstx); + + const builder = configure(factory.getPox5Builder()); + builder.functionName('register-for-bond'); + builder.functionArgs(functionArgs); + builder.postConditionMode(PostConditionMode.Deny); + builder.postConditions([stakingPostCondition]); + const tx = (await builder.build()) as StxLib.Transaction; + + should.equal(tx.stxTransaction.postConditionMode, PostConditionMode.Deny); + should.equal(tx.stxTransaction.postConditions.values.length, 1); + should.equal(tx.stxTransaction.postConditions.values[0].conditionType, PostConditionType.Staking); + should.equal((tx.stxTransaction.postConditions.values[0] as { amount: bigint }).amount, BigInt(amountUstx)); + + // The riskiest path: `initBuilder` must restore the mode AND the post-conditions from the + // deserialized tx, so a `from(hex)` rebuild (the fee re-build) stays byte-identical. + const hex = tx.toBroadcastFormat(); + const rebuilt = factory.from(hex); + rebuilt.fromPubKey(testData.TX_SENDER.pub); + const rebuiltTx = (await rebuilt.build()) as StxLib.Transaction; + + should.equal(rebuiltTx.toBroadcastFormat(), hex); + should.equal(rebuiltTx.stxTransaction.postConditionMode, PostConditionMode.Deny); + should.equal(rebuiltTx.stxTransaction.postConditions.values.length, 1); + should.equal(rebuiltTx.stxTransaction.postConditions.values[0].conditionType, PostConditionType.Staking); + }); + it('validates the PoX-5 contract address during factory routing', async () => { const builder = configure(factory.getPox5Builder()); builder.registerForBond({ @@ -350,9 +380,9 @@ describe('Stacks: PoX-5 Builder', function () { return tx.toJson().payload as StxLib.StacksContractPayload; } - function uintArg(cv: { type: number; value?: unknown }, expected: string): void { + function uintArg(cv: ClarityValue, expected: string): void { cv.type.should.equal(ClarityType.UInt); - String(cv.value).should.equal(expected); + String((cv as { value?: unknown }).value).should.equal(expected); } it('builds a stake transaction carrying the tstxsignet chain ID', async () => { diff --git a/modules/sdk-coin-stx/test/unit/transactionBuilder/sbtcWithdrawBuilder.ts b/modules/sdk-coin-stx/test/unit/transactionBuilder/sbtcWithdrawBuilder.ts index ca75c4adab9..5122ab8f995 100644 --- a/modules/sdk-coin-stx/test/unit/transactionBuilder/sbtcWithdrawBuilder.ts +++ b/modules/sdk-coin-stx/test/unit/transactionBuilder/sbtcWithdrawBuilder.ts @@ -1,6 +1,5 @@ import should from 'should'; -import { ClarityType, pubKeyfromPrivKey, publicKeyToString } from '@stacks/transactions'; -import { ContractCallPayload } from '@stacks/transactions/dist/payload'; +import { ClarityType, ContractCallPayload, privateKeyToPublic, publicKeyToHex } from '@stacks/transactions'; import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test'; import { BitGoAPI } from '@bitgo/sdk-api'; @@ -35,8 +34,8 @@ describe('Stacks: sBTC Withdraw Builder', function () { describe('should build', function () { it('a withdrawal with P2PKH address', async () => { const builder = initTxBuilder(); - const pubKeys = testData.prvKeysString.map(pubKeyfromPrivKey); - const pubKeyStrings = pubKeys.map(publicKeyToString); + const pubKeys = testData.prvKeysString.map(privateKeyToPublic); + const pubKeyStrings = pubKeys.map(publicKeyToHex); builder.fromPubKey(pubKeyStrings); builder.numberSignatures(2); builder.withdraw({ @@ -59,18 +58,18 @@ describe('Stacks: sBTC Withdraw Builder', function () { const recipientTuple = payload.functionArgs[1]; should.equal(recipientTuple.type, ClarityType.Tuple); if (recipientTuple.type === ClarityType.Tuple) { - const hashbytes = recipientTuple.data['hashbytes']; + const hashbytes = recipientTuple.value['hashbytes']; should.equal(hashbytes.type, ClarityType.Buffer); if (hashbytes.type === ClarityType.Buffer) { - hashbytes.buffer.length.should.equal(20); + Buffer.from(hashbytes.value, 'hex').length.should.equal(20); } } }); it('a withdrawal with P2SH address', async () => { const builder = initTxBuilder(); - const pubKeys = testData.prvKeysString.map(pubKeyfromPrivKey); - const pubKeyStrings = pubKeys.map(publicKeyToString); + const pubKeys = testData.prvKeysString.map(privateKeyToPublic); + const pubKeyStrings = pubKeys.map(publicKeyToHex); builder.fromPubKey(pubKeyStrings); builder.numberSignatures(2); builder.withdraw({ @@ -86,17 +85,17 @@ describe('Stacks: sBTC Withdraw Builder', function () { const payload = (tx as StxLib.Transaction).stxTransaction.payload as ContractCallPayload; const recipientTuple = payload.functionArgs[1]; if (recipientTuple.type === ClarityType.Tuple) { - const hashbytes = recipientTuple.data['hashbytes']; + const hashbytes = recipientTuple.value['hashbytes']; if (hashbytes.type === ClarityType.Buffer) { - hashbytes.buffer.length.should.equal(20); + Buffer.from(hashbytes.value, 'hex').length.should.equal(20); } } }); it('a withdrawal with P2WPKH (bech32) address', async () => { const builder = initTxBuilder(); - const pubKeys = testData.prvKeysString.map(pubKeyfromPrivKey); - const pubKeyStrings = pubKeys.map(publicKeyToString); + const pubKeys = testData.prvKeysString.map(privateKeyToPublic); + const pubKeyStrings = pubKeys.map(publicKeyToHex); builder.fromPubKey(pubKeyStrings); builder.numberSignatures(2); builder.withdraw({ @@ -112,17 +111,17 @@ describe('Stacks: sBTC Withdraw Builder', function () { const payload = (tx as StxLib.Transaction).stxTransaction.payload as ContractCallPayload; const recipientTuple = payload.functionArgs[1]; if (recipientTuple.type === ClarityType.Tuple) { - const hashbytes = recipientTuple.data['hashbytes']; + const hashbytes = recipientTuple.value['hashbytes']; if (hashbytes.type === ClarityType.Buffer) { - hashbytes.buffer.length.should.equal(20); + Buffer.from(hashbytes.value, 'hex').length.should.equal(20); } } }); it('a withdrawal with P2WSH (bech32) address', async () => { const builder = initTxBuilder(); - const pubKeys = testData.prvKeysString.map(pubKeyfromPrivKey); - const pubKeyStrings = pubKeys.map(publicKeyToString); + const pubKeys = testData.prvKeysString.map(privateKeyToPublic); + const pubKeyStrings = pubKeys.map(publicKeyToHex); builder.fromPubKey(pubKeyStrings); builder.numberSignatures(2); builder.withdraw({ @@ -138,17 +137,17 @@ describe('Stacks: sBTC Withdraw Builder', function () { const payload = (tx as StxLib.Transaction).stxTransaction.payload as ContractCallPayload; const recipientTuple = payload.functionArgs[1]; if (recipientTuple.type === ClarityType.Tuple) { - const hashbytes = recipientTuple.data['hashbytes']; + const hashbytes = recipientTuple.value['hashbytes']; if (hashbytes.type === ClarityType.Buffer) { - hashbytes.buffer.length.should.equal(32); + Buffer.from(hashbytes.value, 'hex').length.should.equal(32); } } }); it('a withdrawal with P2TR (bech32m) address', async () => { const builder = initTxBuilder(); - const pubKeys = testData.prvKeysString.map(pubKeyfromPrivKey); - const pubKeyStrings = pubKeys.map(publicKeyToString); + const pubKeys = testData.prvKeysString.map(privateKeyToPublic); + const pubKeyStrings = pubKeys.map(publicKeyToHex); builder.fromPubKey(pubKeyStrings); builder.numberSignatures(2); builder.withdraw({ @@ -164,9 +163,9 @@ describe('Stacks: sBTC Withdraw Builder', function () { const payload = (tx as StxLib.Transaction).stxTransaction.payload as ContractCallPayload; const recipientTuple = payload.functionArgs[1]; if (recipientTuple.type === ClarityType.Tuple) { - const hashbytes = recipientTuple.data['hashbytes']; + const hashbytes = recipientTuple.value['hashbytes']; if (hashbytes.type === ClarityType.Buffer) { - hashbytes.buffer.length.should.equal(32); + Buffer.from(hashbytes.value, 'hex').length.should.equal(32); } } }); @@ -175,8 +174,8 @@ describe('Stacks: sBTC Withdraw Builder', function () { describe('round-trip: serialize → deserialize → rebuild', function () { it('should rebuild a signed withdrawal transaction from raw hex', async () => { const builder = initTxBuilder(); - const pubKeys = testData.prvKeysString.map(pubKeyfromPrivKey); - const pubKeyStrings = pubKeys.map(publicKeyToString); + const pubKeys = testData.prvKeysString.map(privateKeyToPublic); + const pubKeyStrings = pubKeys.map(publicKeyToHex); builder.fromPubKey(pubKeyStrings); builder.numberSignatures(2); builder.withdraw({ @@ -199,8 +198,8 @@ describe('Stacks: sBTC Withdraw Builder', function () { it('should rebuild an unsigned withdrawal transaction from raw hex', async () => { const builder = initTxBuilder(); - const pubKeys = testData.prvKeysString.map(pubKeyfromPrivKey); - const pubKeyStrings = pubKeys.map(publicKeyToString); + const pubKeys = testData.prvKeysString.map(privateKeyToPublic); + const pubKeyStrings = pubKeys.map(publicKeyToHex); builder.fromPubKey(pubKeyStrings); builder.numberSignatures(2); builder.withdraw({ @@ -315,8 +314,8 @@ describe('Stacks: sBTC Withdraw Builder', function () { it('should fail to build without withdraw params', async () => { const builder = initTxBuilder(); - const pubKeys = testData.prvKeysString.map(pubKeyfromPrivKey); - const pubKeyStrings = pubKeys.map(publicKeyToString); + const pubKeys = testData.prvKeysString.map(privateKeyToPublic); + const pubKeyStrings = pubKeys.map(publicKeyToHex); builder.fromPubKey(pubKeyStrings); builder.numberSignatures(2); await builder.build().should.be.rejectedWith(/Withdrawal params are not set/); diff --git a/modules/sdk-coin-stx/test/unit/transactionBuilder/sendmanyBuilder.ts b/modules/sdk-coin-stx/test/unit/transactionBuilder/sendmanyBuilder.ts index c505698cf37..e235a428330 100644 --- a/modules/sdk-coin-stx/test/unit/transactionBuilder/sendmanyBuilder.ts +++ b/modules/sdk-coin-stx/test/unit/transactionBuilder/sendmanyBuilder.ts @@ -1,7 +1,6 @@ import should from 'should'; -import { StacksTestnet, StacksMainnet } from '@stacks/network'; -import { pubKeyfromPrivKey, publicKeyToString } from '@stacks/transactions'; -import { principalToString } from '@stacks/transactions/dist/clarity/types/principalCV'; +import { STACKS_TESTNET, STACKS_MAINNET } from '@stacks/network'; +import { cvToString, privateKeyToPublic, publicKeyToHex } from '@stacks/transactions'; import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test'; import { BitGoAPI } from '@bitgo/sdk-api'; @@ -42,8 +41,8 @@ describe('Stacks: Send Many Builder', function () { const txBuilder: any = factory.getSendmanyBuilder(); const txBuilderProd: any = factoryProd.getSendmanyBuilder(); - txBuilder._network.should.deepEqual(new StacksTestnet()); - txBuilderProd._network.should.deepEqual(new StacksMainnet()); + txBuilder._network.should.deepEqual(STACKS_TESTNET); + txBuilderProd._network.should.deepEqual(STACKS_MAINNET); }); describe('should build ', function () { @@ -51,8 +50,8 @@ describe('Stacks: Send Many Builder', function () { const builder = initTxBuilder(); // corresponding public keys - const pubKeys = testData.prvKeysString.map(pubKeyfromPrivKey); - const pubKeyStrings = pubKeys.map(publicKeyToString); + const pubKeys = testData.prvKeysString.map(privateKeyToPublic); + const pubKeyStrings = pubKeys.map(publicKeyToHex); builder.fromPubKey(pubKeyStrings); for (const recipient of testData.sendManyRecipients) { builder.send(recipient); @@ -75,8 +74,10 @@ describe('Stacks: Send Many Builder', function () { list.length.should.equal(testData.sendManyRecipients.length); for (let i = 0; i < testData.sendManyRecipients.length; i++) { list[i].data.should.have.properties(['to', 'ustx', 'memo']); - principalToString(list[i].data.to).should.equal(testData.sendManyRecipients[i].address); - list[i].data.memo.buffer.toString('ascii').should.equal(testData.sendManyRecipients[i].memo); + cvToString(list[i].data.to).should.equal(testData.sendManyRecipients[i].address); + Buffer.from(list[i].data.memo.value, 'hex') + .toString('ascii') + .should.equal(testData.sendManyRecipients[i].memo); list[i].data.ustx.value.should.equal(testData.sendManyRecipients[i].amount); } tx.outputs.length.should.equal(testData.sendManyRecipients.length); diff --git a/modules/sdk-coin-stx/test/unit/transactionBuilder/transferBuilder.ts b/modules/sdk-coin-stx/test/unit/transactionBuilder/transferBuilder.ts index b802c8c3ee3..6191f40c5e0 100644 --- a/modules/sdk-coin-stx/test/unit/transactionBuilder/transferBuilder.ts +++ b/modules/sdk-coin-stx/test/unit/transactionBuilder/transferBuilder.ts @@ -1,6 +1,6 @@ import assert from 'assert'; import should from 'should'; -import { StacksMainnet, StacksTestnet } from '@stacks/network'; +import { STACKS_MAINNET, STACKS_TESTNET } from '@stacks/network'; import { TransactionType, rawPrvToExtendedKeys } from '@bitgo/sdk-core'; import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test'; @@ -46,8 +46,8 @@ describe('Stacks: Send Many Builder', function () { const txBuilder: any = factory.getTransferBuilder(); const txBuilderProd: any = factoryProd.getTransferBuilder(); - txBuilder._network.should.deepEqual(new StacksTestnet()); - txBuilderProd._network.should.deepEqual(new StacksMainnet()); + txBuilder._network.should.deepEqual(STACKS_TESTNET); + txBuilderProd._network.should.deepEqual(STACKS_MAINNET); }); }); diff --git a/modules/sdk-coin-stx/test/unit/tstxsignet.ts b/modules/sdk-coin-stx/test/unit/tstxsignet.ts index 75f72a50896..2854e438e1c 100644 --- a/modules/sdk-coin-stx/test/unit/tstxsignet.ts +++ b/modules/sdk-coin-stx/test/unit/tstxsignet.ts @@ -3,7 +3,7 @@ import assert from 'assert'; import { BitGoAPI } from '@bitgo/sdk-api'; import { Wallet } from '@bitgo/sdk-core'; import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test'; -import { TransactionVersion } from '@stacks/transactions'; +import { TransactionVersion } from '@stacks/network'; import should from 'should'; import { StxLib, Tstxsignet } from '../../src'; diff --git a/modules/sdk-coin-stx/test/unit/util.ts b/modules/sdk-coin-stx/test/unit/util.ts index 70fccc22542..dd730d54b02 100644 --- a/modules/sdk-coin-stx/test/unit/util.ts +++ b/modules/sdk-coin-stx/test/unit/util.ts @@ -271,12 +271,12 @@ describe('Stx util library', function () { describe('stringifyCv', function () { it('Int type', function () { const input: IntCV = { type: ClarityType.Int, value: BigInt('100000') }; - Utils.stringifyCv(input).should.deepEqual({ type: 0, value: '100000' }); + Utils.stringifyCv(input).should.deepEqual({ type: ClarityType.Int, value: '100000' }); }); it('UInt type', function () { const input: UIntCV = { type: ClarityType.UInt, value: BigInt('100000') }; - Utils.stringifyCv(input).should.deepEqual({ type: 1, value: '100000' }); + Utils.stringifyCv(input).should.deepEqual({ type: ClarityType.UInt, value: '100000' }); }); it('OptionalNone type', function () { @@ -289,7 +289,10 @@ describe('Stx util library', function () { type: ClarityType.OptionalSome, value: { type: ClarityType.UInt, value: BigInt('100000') }, }; - Utils.stringifyCv(input).should.deepEqual({ type: 10, value: { type: 1, value: '100000' } }); + Utils.stringifyCv(input).should.deepEqual({ + type: ClarityType.OptionalSome, + value: { type: ClarityType.UInt, value: '100000' }, + }); }); it('OptionalSome type with tuple value', function () { @@ -297,23 +300,35 @@ describe('Stx util library', function () { type: ClarityType.OptionalSome, value: { type: ClarityType.Tuple, - data: { - hashbytes: { type: ClarityType.Buffer, buffer: Buffer.from('some-hash') }, - version: { type: ClarityType.Buffer, buffer: new BigNum(1).toBuffer() }, + value: { + hashbytes: { type: ClarityType.Buffer, value: Buffer.from('some-hash').toString('hex') }, + version: { type: ClarityType.Buffer, value: new BigNum(1).toBuffer().toString('hex') }, }, }, }; - Utils.stringifyCv(input).should.deepEqual(input); + Utils.stringifyCv(input).should.deepEqual({ + type: ClarityType.OptionalSome, + value: { + type: ClarityType.Tuple, + data: { + hashbytes: { type: ClarityType.Buffer, value: '736f6d652d68617368' }, + version: { type: ClarityType.Buffer, value: '01' }, + }, + }, + }); }); it('List type', function () { - const input: ListCV = { type: ClarityType.List, list: [{ type: ClarityType.UInt, value: BigInt('100000') }] }; - Utils.stringifyCv(input).should.deepEqual({ type: 11, list: [{ type: 1, value: '100000' }] }); + const input: ListCV = { type: ClarityType.List, value: [{ type: ClarityType.UInt, value: BigInt('100000') }] }; + Utils.stringifyCv(input).should.deepEqual({ + type: ClarityType.List, + list: [{ type: ClarityType.UInt, value: '100000' }], + }); }); it('List type with empty list', function () { - const input: ListCV = { type: ClarityType.List, list: [] }; - Utils.stringifyCv(input).should.deepEqual({ type: 11, list: [] }); + const input: ListCV = { type: ClarityType.List, value: [] }; + Utils.stringifyCv(input).should.deepEqual({ type: ClarityType.List, list: [] }); }); it('ResponseOk type with uint value', function () { @@ -321,7 +336,10 @@ describe('Stx util library', function () { type: ClarityType.ResponseOk, value: { type: ClarityType.UInt, value: BigInt('100000') }, }; - Utils.stringifyCv(input).should.deepEqual({ type: 7, value: { type: 1, value: '100000' } }); + Utils.stringifyCv(input).should.deepEqual({ + type: ClarityType.ResponseOk, + value: { type: ClarityType.UInt, value: '100000' }, + }); }); it('ResponseErr type with uint value', function () { @@ -329,7 +347,10 @@ describe('Stx util library', function () { type: ClarityType.ResponseErr, value: { type: ClarityType.UInt, value: BigInt('10000') }, }; - Utils.stringifyCv(input).should.deepEqual({ type: 8, value: { type: 1, value: '10000' } }); + Utils.stringifyCv(input).should.deepEqual({ + type: ClarityType.ResponseErr, + value: { type: ClarityType.UInt, value: '10000' }, + }); }); }); diff --git a/yarn.lock b/yarn.lock index 54d8c89d19d..4cfe70cf140 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5599,32 +5599,6 @@ "@stacks/network" "^7.6.0" "@stacks/transactions" "^7.6.0" -"@stacks/common@^1.2.2": - version "1.2.2" - resolved "https://registry.npmjs.org/@stacks/common/-/common-1.2.2.tgz" - integrity sha512-knCqq88EBRCN8AhS7+Sx2PJuRv0EFNChEpqLqCAchCHCQfp5bWad/47Zw+fLP9ccBwFXh4pl1wDtbQLBfDo0+A== - dependencies: - cross-fetch "^3.0.6" - -"@stacks/common@^2.0.1": - version "2.0.2" - resolved "https://registry.npmjs.org/@stacks/common/-/common-2.0.2.tgz" - integrity sha512-RpuNIqf+XmcHlMjXeVZE4fS3yIUlCvOYmxyBKOarh010Kx3Gs/LhAeejn/329lYcIE6VwNPoeXPSE9deq7Yjcw== - dependencies: - "@types/node" "^14.14.43" - bn.js "^4.12.0" - buffer "^6.0.3" - cross-fetch "^3.1.4" - -"@stacks/common@^4.3.5": - version "4.3.5" - resolved "https://registry.npmjs.org/@stacks/common/-/common-4.3.5.tgz" - integrity sha512-UuViiQ7fn3vdtTe3739aRzbl+wbukekeQuXgqt8d7nB2HC2HodD7GcHhpUga165cO35CD6lQUtj3vXxJb5Ga+A== - dependencies: - "@types/bn.js" "^5.1.0" - "@types/node" "^18.0.4" - buffer "^6.0.3" - "@stacks/common@^7.6.0": version "7.6.0" resolved "https://registry.npmjs.org/@stacks/common/-/common-7.6.0.tgz#65b756f0f19ce64a7ebd9ffe05bbb43bd9562ee6" @@ -5644,21 +5618,6 @@ ripemd160-min "^0.0.6" varuint-bitcoin "^1.1.2" -"@stacks/network@^1.2.2": - version "1.2.2" - resolved "https://registry.npmjs.org/@stacks/network/-/network-1.2.2.tgz" - integrity sha512-xcWwuRrLJn9qqi3PEBcP2UPZHQztTZd31C0aVlzYHttNMir/sY9SrUqSnw45z2Jo4O9pIYYPIiPRtdV91Ho3fw== - dependencies: - "@stacks/common" "^1.2.2" - -"@stacks/network@^4.3.0": - version "4.3.5" - resolved "https://registry.npmjs.org/@stacks/network/-/network-4.3.5.tgz" - integrity sha512-TC4+AkuT6qi3MoEGxTftA+4BNp99QvGnI+qtKQkoA1m0KDr8b9hSBUhugJHRhQbWuo7D6q0+JagYEGxLID29Kw== - dependencies: - "@stacks/common" "^4.3.5" - cross-fetch "^3.1.5" - "@stacks/network@^7.6.0": version "7.6.0" resolved "https://registry.npmjs.org/@stacks/network/-/network-7.6.0.tgz#39dadc33e194cf77541c1bd9fdccaff596b719dc" @@ -5667,29 +5626,7 @@ "@stacks/common" "^7.6.0" cross-fetch "^3.1.5" -"@stacks/transactions@2.0.1": - version "2.0.1" - resolved "https://registry.npmjs.org/@stacks/transactions/-/transactions-2.0.1.tgz" - integrity sha512-q+8nCbn+0m1T8NbGG2sfMcBcCxdaH/F+vgBEHkhMIFHFLYXVYBGYbTX2llGS9StLp/tQq6p2Bfb1kzKFSw8FRQ== - dependencies: - "@stacks/common" "^2.0.1" - "@stacks/network" "^1.2.2" - "@types/bn.js" "^4.11.6" - "@types/elliptic" "^6.4.12" - "@types/node" "^14.14.43" - "@types/randombytes" "^2.0.0" - "@types/sha.js" "^2.4.0" - bn.js "^4.12.0" - c32check "^1.1.2" - cross-fetch "^3.1.4" - elliptic "^6.5.4" - lodash "^4.17.20" - randombytes "^2.1.0" - ripemd160-min "^0.0.6" - sha.js "^2.4.11" - smart-buffer "^4.1.0" - -"@stacks/transactions@^7.6.0": +"@stacks/transactions@7.6.0", "@stacks/transactions@^7.6.0": version "7.6.0" resolved "https://registry.npmjs.org/@stacks/transactions/-/transactions-7.6.0.tgz#4113a50ce993e1e5fff7d2b791a6c9573644e765" integrity sha512-s7F7eJtQVnZoB79j8pY9SLKhlvvdYZZD1NSDRWrFjzSJTDmxptL8c50S563WFja6KOhFOgg1jd+p0XWTxFAVyA== @@ -6057,7 +5994,7 @@ dependencies: "@types/node" "*" -"@types/bn.js@^4.11.3", "@types/bn.js@^4.11.6": +"@types/bn.js@^4.11.3": version "4.11.6" resolved "https://registry.npmjs.org/@types/bn.js/-/bn.js-4.11.6.tgz" integrity sha512-pqr857jrp2kPuO9uRjZ3PwnJTjoQy+fcdxvBTvHm6dkmEL9q+hDD/2j/0ELOBPtPnS8LjCX0gI9nbl8lVkadpg== @@ -6160,7 +6097,7 @@ "@types/expect" "^1.20.4" "@types/node" "*" -"@types/elliptic@^6.4.12", "@types/elliptic@^6.4.9": +"@types/elliptic@^6.4.9": version "6.4.18" resolved "https://registry.npmjs.org/@types/elliptic/-/elliptic-6.4.18.tgz" integrity sha512-UseG6H5vjRiNpQvrhy4VF/JXdA3V/Fp5amvveaL+fs28BZ6xIKJBPnUPRlEaZpysD9MbpfaLi8lbl7PGUAkpWw== @@ -6397,12 +6334,7 @@ resolved "https://registry.npmjs.org/@types/node/-/node-12.20.55.tgz" integrity sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ== -"@types/node@^14.14.43": - version "14.18.63" - resolved "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz" - integrity sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ== - -"@types/node@^18.0.0", "@types/node@^18.0.4": +"@types/node@^18.0.0": version "18.19.130" resolved "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz#da4c6324793a79defb7a62cba3947ec5add00d59" integrity sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg== @@ -6539,13 +6471,6 @@ "@types/node" "*" "@types/send" "*" -"@types/sha.js@^2.4.0": - version "2.4.4" - resolved "https://registry.npmjs.org/@types/sha.js/-/sha.js-2.4.4.tgz" - integrity sha512-Qukd+D6S2Hm0wLVt2Vh+/eWBIoUt+wF8jWjBsG4F8EFQRwKtYvtXCPcNl2OEUQ1R+eTr3xuSaBYUyM3WD1x/Qw== - dependencies: - "@types/node" "*" - "@types/sinon@^10.0.0", "@types/sinon@^10.0.11": version "10.0.20" resolved "https://registry.npmjs.org/@types/sinon/-/sinon-10.0.20.tgz" @@ -7815,7 +7740,7 @@ base-64@^0.1.0: resolved "https://registry.npmjs.org/base-64/-/base-64-0.1.0.tgz" integrity sha512-Y5gU45svrR5tI2Vt/X9GPd3L0HNIKzGu202EjxrXMpuc2V2CiKgemAbUUsqYmZJvPtCXoUKjNZwBJzsNScUbXA== -base-x@^3.0.2, base-x@^3.0.6, base-x@^3.0.8: +base-x@^3.0.2, base-x@^3.0.6: version "3.0.11" resolved "https://registry.npmjs.org/base-x/-/base-x-3.0.11.tgz" integrity sha512-xz7wQ8xDhdyP7tQxwdteLYeFfS68tSMNCZ/Y37WJ4bhGfKPpqEIlmIyueQHqOyoPhE6xNUqjzRr8ra0eF9VRvA== @@ -8438,7 +8363,7 @@ buffer-xor@^1.0.2, buffer-xor@^1.0.3: resolved "https://registry.npmjs.org/buffer-xor/-/buffer-xor-1.0.3.tgz" integrity sha512-571s0T7nZWK6vB67HI5dyUF7wXiNcfaPPPTl6zYCNApANjIvYJTg7hlud/+cJpdAhS7dVzqMLmfhfHR3rAcOjQ== -buffer@4.9.2, buffer@5.7.1, buffer@6.0.3, buffer@^5.0.2, buffer@^5.1.0, buffer@^5.2.1, buffer@^5.4.3, buffer@^5.5.0, buffer@^5.6.0, buffer@^5.7.1, buffer@^6.0.2, buffer@^6.0.3, buffer@~6.0.3: +buffer@4.9.2, buffer@5.7.1, buffer@6.0.3, buffer@^5.0.2, buffer@^5.1.0, buffer@^5.2.1, buffer@^5.4.3, buffer@^5.5.0, buffer@^5.7.1, buffer@^6.0.2, buffer@^6.0.3, buffer@~6.0.3: version "6.0.3" resolved "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz" integrity sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA== @@ -8477,15 +8402,6 @@ bytes@3.1.2: resolved "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz" integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== -c32check@^1.1.2: - version "1.1.3" - resolved "https://registry.npmjs.org/c32check/-/c32check-1.1.3.tgz" - integrity sha512-ADADE/PjAbJRlwpG3ShaOMbBUlJJZO7xaYSRD5Tub6PixQlgR4s36y9cvMf/YRGpkqX+QOxIdMw216iC320q9A== - dependencies: - base-x "^3.0.8" - buffer "^5.6.0" - cross-sha256 "^1.2.0" - c32check@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/c32check/-/c32check-2.0.0.tgz#b9365618b2fb135c0783d03f00605b7b0f90c659" @@ -9554,20 +9470,13 @@ cross-env@^7.0.3: dependencies: cross-spawn "^7.0.1" -cross-fetch@^3.0.6, cross-fetch@^3.1.4, cross-fetch@^3.1.5: +cross-fetch@^3.1.5: version "3.2.0" resolved "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.2.0.tgz" integrity sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q== dependencies: node-fetch "^2.7.0" -cross-sha256@^1.2.0: - version "1.2.0" - resolved "https://registry.npmjs.org/cross-sha256/-/cross-sha256-1.2.0.tgz" - integrity sha512-KViLNMDZKV7jwFqjFx+rNhG26amnFYYQ0S+VaFlVvpk8tM+2XbFia/don/SjGHg9WQxnFVi6z64CGPuF3T+nNw== - dependencies: - buffer "^5.6.0" - cross-spawn@^7.0.0, cross-spawn@^7.0.1, cross-spawn@^7.0.2, cross-spawn@^7.0.3, cross-spawn@^7.0.6: version "7.0.6" resolved "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz" @@ -18792,7 +18701,7 @@ slice-ansi@^5.0.0: ansi-styles "^6.0.0" is-fullwidth-code-point "^4.0.0" -smart-buffer@^4.1.0, smart-buffer@^4.2.0: +smart-buffer@^4.2.0: version "4.2.0" resolved "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz" integrity sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg== From 8eb89e683ba7c0ea268aa9db340f1aa8c0efce63 Mon Sep 17 00:00:00 2001 From: Doddanna17 Date: Sat, 17 Oct 2026 11:13:24 +0530 Subject: [PATCH 2/2] test(sdk-coin-stx): cover builders, recovery and validators Add unit tests for the branches left uncovered across the module: coin registration and base-coin methods, transaction builder validators, transfer/sendmany/fungible-token/contract builders, the sBTC withdraw deserialization guards, PoX-5 calculate/claim rewards, the transaction builder factory, transaction error branches, key pair, btc address decoding, sip10 token metadata and the stx node-request/recovery paths. Also pins the token-name lookup fixed in the migration commit: a contract that is not a known SIP-10 token resolves to undefined instead of throwing a TypeError, building a token transfer for it fails with the intended 'invalid contract address or contract name, not supported', and the fungible-token builder now fails the build rather than serialising an 'undefined' asset name. Closes the sBTC token-transfer gap (tstx:tsbtc / stx:sbtc resolve from statics and emit a sbtc-token::sbtc-token post-condition, byte-identical across a factory.from(hex) rebuild). Pins the multisig key encodings against v2.0.1-generated fixtures: the uncompressed build + appendOrigin bytes match v2 exactly, a compressed-key multisig tx verifies and round-trips byte-identically, and the uncompressed-key rebuild/verification limitation is asserted (pre-existing and identical in v2.0.1, v6.17.0 and v7.6.0) instead of assumed. Test/coverage status vs the pre-upgrade baseline (module): - tests: ~226 -> 328 passing; test files 16 -> 21 - coverage (c8, src/**): ~93.5% stmts / ~81.3% branch -> 98.98% stmts / 91.27% branch / 99.07% funcs Ticket: SI-2024 --- .../sdk-coin-stx/test/unit/btcAddressUtils.ts | 24 +++ modules/sdk-coin-stx/test/unit/keyPair.ts | 6 + modules/sdk-coin-stx/test/unit/register.ts | 115 +++++++++++++ modules/sdk-coin-stx/test/unit/resources.ts | 6 + modules/sdk-coin-stx/test/unit/sip10Token.ts | 62 +++++++ modules/sdk-coin-stx/test/unit/stx.ts | 77 +++++++++ .../sdk-coin-stx/test/unit/stxNodeErrors.ts | 161 ++++++++++++++++++ modules/sdk-coin-stx/test/unit/transaction.ts | 27 +++ .../transactionBuilder/builderValidators.ts | 105 ++++++++++++ .../transactionBuilder/contractBuilder.ts | 43 +++++ .../fungibleTokenTransferBuilder.ts | 95 +++++++++++ .../unit/transactionBuilder/pox5Builder.ts | 26 +++ .../transactionBuilder/sbtcWithdrawBuilder.ts | 98 ++++++++++- .../transactionBuilder/sendmanyBuilder.ts | 28 +++ .../transactionBuilder/transferBuilder.ts | 83 +++++++++ .../test/unit/transactionBuilderFactory.ts | 31 ++++ modules/sdk-coin-stx/test/unit/tstxsignet.ts | 11 ++ modules/sdk-coin-stx/test/unit/util.ts | 98 +++++++++++ 18 files changed, 1095 insertions(+), 1 deletion(-) create mode 100644 modules/sdk-coin-stx/test/unit/register.ts create mode 100644 modules/sdk-coin-stx/test/unit/stxNodeErrors.ts create mode 100644 modules/sdk-coin-stx/test/unit/transactionBuilder/builderValidators.ts create mode 100644 modules/sdk-coin-stx/test/unit/transactionBuilderFactory.ts diff --git a/modules/sdk-coin-stx/test/unit/btcAddressUtils.ts b/modules/sdk-coin-stx/test/unit/btcAddressUtils.ts index e844a031fd1..ad701694f5b 100644 --- a/modules/sdk-coin-stx/test/unit/btcAddressUtils.ts +++ b/modules/sdk-coin-stx/test/unit/btcAddressUtils.ts @@ -1,3 +1,5 @@ +import * as bs58check from 'bs58check'; +import { bech32, bech32m } from 'bech32'; import should from 'should'; import { decodeBtcAddress, isValidBtcAddress, SbtcAddressVersion } from '../../src/lib/btcAddressUtils'; @@ -85,4 +87,26 @@ describe('btcAddressUtils', function () { isValidBtcAddress('SP10FDHQQ4F2F0KHMN6Z24RMAMGX5933SQJCWKAAR').should.be.false(); // STX address }); }); + + describe('decodeBtcAddress error branches', function () { + it('should reject a base58check payload whose hash is not 20 bytes', function () { + const address = bs58check.encode(Buffer.concat([Buffer.from([0x00]), Buffer.alloc(19)])); + should.throws(() => decodeBtcAddress(address), /Unable to decode Bitcoin address/); + }); + + it('should reject an unknown base58check version byte', function () { + const address = bs58check.encode(Buffer.concat([Buffer.from([0x99]), Buffer.alloc(20)])); + should.throws(() => decodeBtcAddress(address), /Unable to decode Bitcoin address/); + }); + + it('should reject an invalid witness v0 program length', function () { + const address = bech32.encode('bc', [0, ...bech32.toWords(Buffer.alloc(25))]); + should.throws(() => decodeBtcAddress(address), /Invalid witness v0 program length/); + }); + + it('should reject an invalid witness v1 program length', function () { + const address = bech32m.encode('bc', [1, ...bech32m.toWords(Buffer.alloc(20))]); + should.throws(() => decodeBtcAddress(address), /Invalid witness v1 program length/); + }); + }); }); diff --git a/modules/sdk-coin-stx/test/unit/keyPair.ts b/modules/sdk-coin-stx/test/unit/keyPair.ts index ae6968e066b..2ea1d209d7a 100644 --- a/modules/sdk-coin-stx/test/unit/keyPair.ts +++ b/modules/sdk-coin-stx/test/unit/keyPair.ts @@ -120,4 +120,10 @@ describe('Stx KeyPair', function () { should.exist(pub); }); }); + + describe('constructor', function () { + it('should reject source options that are not a seed, private key or public key', () => { + should.throws(() => new StxLib.KeyPair({ notAKey: true } as never), /Invalid key pair options/); + }); + }); }); diff --git a/modules/sdk-coin-stx/test/unit/register.ts b/modules/sdk-coin-stx/test/unit/register.ts new file mode 100644 index 00000000000..2b1456e0f8a --- /dev/null +++ b/modules/sdk-coin-stx/test/unit/register.ts @@ -0,0 +1,115 @@ +import assert from 'assert'; + +import { BitGoAPI } from '@bitgo/sdk-api'; +import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test'; +import { coins } from '@bitgo/statics'; + +import { Sip10Token, Stx, Tstx, Tstxsignet, register } from '../../src'; + +describe('STX coin registration and base coin behaviour', function () { + let bitgo: TestBitGoAPI; + + before(function () { + bitgo = TestBitGo.decorate(BitGoAPI, { env: 'mock' }); + bitgo.initializeTestVars(); + bitgo.safeRegister('stx', Stx.createInstance); + bitgo.safeRegister('tstx', Tstx.createInstance); + bitgo.safeRegister('tstxsignet', Tstxsignet.createInstance); + }); + + describe('register', function () { + it('registers every coin and sip10 token constructor', function () { + const registered: { name: string; coinConstructor: unknown }[] = []; + const sdk = { + register: (name: string, coinConstructor: unknown) => { + registered.push({ name, coinConstructor }); + }, + } as never; + + register(sdk); + + const names = registered.map((entry) => entry.name); + assert(names.includes('stx'), 'stx should be registered'); + assert(names.includes('tstx'), 'tstx should be registered'); + assert(names.includes('tstxsignet'), 'tstxsignet should be registered'); + + const tokenConstructors = Sip10Token.createTokenConstructors(); + assert(tokenConstructors.length > 0, 'expected at least one sip10 token constructor'); + assert.strictEqual(registered.length, 3 + tokenConstructors.length); + + // the registered constructors must produce coins for their own chain + for (const name of ['stx', 'tstx', 'tstxsignet']) { + const ctor = registered.find((entry) => entry.name === name)?.coinConstructor as ( + bitgo: unknown, + staticsCoin: unknown + ) => Stx; + assert.strictEqual(typeof ctor, 'function'); + assert.strictEqual(ctor(bitgo, coins.get(name)).getChain(), name); + } + + for (const { name } of tokenConstructors) { + assert(names.includes(name), `${name} should be registered`); + } + }); + }); + + describe('constructor', function () { + it('requires the statics coin', function () { + assert.throws(() => new Stx(bitgo), /missing required constructor parameter staticsCoin/); + assert.throws(() => new Tstx(bitgo), /missing required constructor parameter staticsCoin/); + assert.throws(() => new Tstxsignet(bitgo), /missing required constructor parameter staticsCoin/); + }); + }); + + describe('tstx coin', function () { + it('exposes the statics chain metadata', function () { + const basecoin = bitgo.coin('tstx') as Tstx; + assert.strictEqual(basecoin.getChain(), 'tstx'); + assert.strictEqual(basecoin.getFullName(), coins.get('tstx').fullName); + assert.strictEqual(basecoin.getFamily(), 'stx'); + }); + }); + + describe('stx base coin methods', function () { + let basecoin: Stx; + + before(function () { + basecoin = bitgo.coin('stx') as Stx; + }); + + it('returns the base factor and default multisig type', function () { + assert.strictEqual(basecoin.getBaseFactor(), Math.pow(10, coins.get('stx').decimalPlaces)); + assert.strictEqual(basecoin.getDefaultMultisigType(), 'onchain'); + }); + + it('returns a transfer builder from getTransaction', function () { + const builder = basecoin.getTransaction(coins.get('stx')); + assert.strictEqual(builder.coinName(), 'stx'); + }); + + it('returns an empty object from parseTransaction', async function () { + const parsed = await basecoin.parseTransaction({ anything: true }); + assert.deepStrictEqual(parsed, {}); + }); + + it('rejects explainTransaction without txHex or feeInfo', async function () { + await assert.rejects(basecoin.explainTransaction({} as never), /missing explain tx parameters/); + }); + + it('does not implement auditDecryptedKey', function () { + assert.throws(() => basecoin.auditDecryptedKey({} as never)); + }); + + it('rejects keychains that are not a 3-key wallet on isWalletAddress', async function () { + await assert.rejects( + basecoin.isWalletAddress({ address: 'STB44HYPYAT2BB2QE513NSP81HTMYWBJP02HPGK6', keychains: [] } as never), + /Invalid keychains/ + ); + }); + + it('returns false for invalid public and private keys', function () { + assert.strictEqual(basecoin.isValidPub(null as never), false); + assert.strictEqual(basecoin.isValidPrv(null as never), false); + }); + }); +}); diff --git a/modules/sdk-coin-stx/test/unit/resources.ts b/modules/sdk-coin-stx/test/unit/resources.ts index b09ee59da4c..64d82db32b0 100644 --- a/modules/sdk-coin-stx/test/unit/resources.ts +++ b/modules/sdk-coin-stx/test/unit/resources.ts @@ -123,6 +123,12 @@ export const pub3 = '038e3c4529395611be9abf6fa3b6987e81d402385e3d605a073f42f4075 export const MULTI_SIG_SIGNED_TRANSACTION = '808000000004012fe507c09dbb23c3b7e5d166c81fc4b87692510b000000000000000000000000000000b400000003020123d6d3d22d2ca1b7310f5c45314d1e783c03193e1bb5e08685fc1a48f8bdac7c0d93a463efee18b9b41fffe38d4b4c7475809a37c0fd53f52cef456a8d9280db020010b311f82b1b111eb5efa16c4790687b16dc92b7b4d07a1f0f1fe65baee43eb02ede226f807a097237e7d0ee3aa4fdf9b6287dd23d819ec560df180875f4019a00038e3c4529395611be9abf6fa3b6987e81d402385e3d605a073f42f407565a4a3d000203020000000000051a1ae3f911d8f1d46d7416bfbe4b593fd41eac19cb00000000000003e874657374206d656d6f00000000000000000000000000000000000000000000000000'; +// v2-generated (@stacks/transactions 2.0.1) 2-of-3 multisig transfer from UNCOMPRESSED pubkeys. +export const UNCOMPRESSED_MULTISIG_UNSIGNED_TRANSFER = + '80800000000401cb97b1dfe00ed612c19b8131cd8678e51f144e96000000000000000000000000000000b4000000030102b087ca52f40fdfdf4b16a0bbf7e91e4db2e183ac5c6491a5f60a5450e25de7d001024abddd63b56c55cd1ed0803c26c473f5f0b9d8473b37b65bd812f035365f154b01038e3c4529395611be9abf6fa3b6987e81d402385e3d605a073f42f407565a4a3d000203020000000000051a1ae3f911d8f1d46d7416bfbe4b593fd41eac19cb00000000000003e800000000000000000000000000000000000000000000000000000000000000000000'; +export const UNCOMPRESSED_MULTISIG_SIGNED_TRANSFER = + '80800000000401cb97b1dfe00ed612c19b8131cd8678e51f144e96000000000000000000000000000000b4000000030201dfbb6fb2e6d2d7388259104ab9f62b65157efef7a68cede38ad5c25908e493a50aafd52622dbcacb8eb91c54f7983fef94430d8799ba614b1ce6cd916764e0ad0201130f0e5d2dfd80b0b83126ff46a6a3f0cbc83cd83de00b075b3572e676fa6efc23faf02a11b4014c2c0de6b2ada5b591243e2a0ab11923af2e64bd72242f02a401038e3c4529395611be9abf6fa3b6987e81d402385e3d605a073f42f407565a4a3d000203020000000000051a1ae3f911d8f1d46d7416bfbe4b593fd41eac19cb00000000000003e800000000000000000000000000000000000000000000000000000000000000000000'; + // contract call export const CONTRACT_ADDRESS = 'ST000000000000000000002AMW42H'; export const CONTRACT_NAME = 'pox-4'; diff --git a/modules/sdk-coin-stx/test/unit/sip10Token.ts b/modules/sdk-coin-stx/test/unit/sip10Token.ts index 12bb4d85d64..6428407b014 100644 --- a/modules/sdk-coin-stx/test/unit/sip10Token.ts +++ b/modules/sdk-coin-stx/test/unit/sip10Token.ts @@ -1,8 +1,10 @@ import _ from 'lodash'; import nock from 'nock'; +import should from 'should'; import { BitGoAPI } from '@bitgo/sdk-api'; import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test'; import { ITransactionRecipient, Wallet, Memo } from '@bitgo/sdk-core'; +import { coins } from '@bitgo/statics'; import { Sip10Token } from '../../src'; import * as testData from '../fixtures'; @@ -283,6 +285,34 @@ describe('Sip10Token:', function () { assert.deepEqual(response.txHex, testData.HOT_WALLET_TOKEN_RECOVERY_TX_HEX, 'tx hex not matching!'); }); + it('should build a signed token recover transaction to a destination with a memo id', async () => { + const rootAddress = testData.HOT_WALLET_ROOT_ADDRESS; + nock('https://api.testnet.hiro.so') + .get(`/extended/v2/addresses/${rootAddress}/balances/stx`) + .reply(200, testData.ACCOUNT_BALANCE_RESPONSE); + nock('https://api.testnet.hiro.so') + .get(`/extended/v2/addresses/${rootAddress}/balances/ft/${testData.STX_TOKEN_ASSET_ID}`) + .reply(200, testData.TOKEN_BALANCE_RESPONSE); + nock('https://api.testnet.hiro.so') + .get(`/extended/v1/address/${rootAddress}/nonces`) + .reply(200, testData.ACCOUNT_NONCE_RESPONSE); + nock('https://api.testnet.hiro.so', { allowUnmocked: true }) + .post(`/v2/fees/transaction`) + .reply(200, testData.FEE_ESTIMATION_TOKEN_RESPONSE); + + const recoveryOptions: RecoveryOptions = { + backupKey: testData.HOT_WALLET_KEY_CARD_INFO.BACKUP_KEY, + userKey: testData.HOT_WALLET_KEY_CARD_INFO.USER_KEY, + rootAddress: rootAddress, + recoveryDestination: `${testData.DESTINATION_ADDRESS_WRW}?memoId=8`, + bitgoKey: testData.HOT_WALLET_KEY_CARD_INFO.BITGO_PUB_KEY, + walletPassphrase: testData.HOT_WALLET_KEY_CARD_INFO.WALLET_PASSPHRASE, + contractId: 'STAG18E45W613FZ3H4ZMF6QHH426EXM5QTSAVWYH.tsip6dp-token', + }; + const response = (await basecoin.recover(recoveryOptions)) as RecoveryTransaction; + should.exist(response.txHex); + }); + it('should build an unsigned token transaction when public keys are passed', async () => { const rootAddress = testData.COLD_WALLET_ROOT_ADDRESS; nock(`https://api.testnet.hiro.so`) @@ -363,4 +393,36 @@ describe('Sip10Token:', function () { .should.rejectedWith('invalid contract id, please provide it in the form (contractAddress.contractName)'); }); }); + + describe('Sip10 token metadata', function () { + it('exposes the configured token properties', function () { + const config = ( + basecoin as unknown as { + tokenConfig: { + name: string; + coin: string; + network: string; + assetId: string; + decimalPlaces: number; + type: string; + }; + } + ).tokenConfig; + + basecoin.name.should.equal(config.name); + basecoin.coin.should.equal(config.coin); + basecoin.network.should.equal(config.network); + basecoin.assetId.should.equal(config.assetId); + basecoin.decimalPlaces.should.equal(config.decimalPlaces); + basecoin.getChain().should.equal(config.type); + basecoin.getBaseChain().should.equal(config.coin); + basecoin.getFullName().should.equal('Sip10 Token'); + basecoin.getBaseFactor().should.equal(Math.pow(10, config.decimalPlaces)); + }); + + it('returns a fungible token transfer builder from getTransaction', function () { + const builder = basecoin.getTransaction(coins.get(basecoin.getChain())); + should.exist(builder); + }); + }); }); diff --git a/modules/sdk-coin-stx/test/unit/stx.ts b/modules/sdk-coin-stx/test/unit/stx.ts index 9a22e92b881..e30f47d276f 100644 --- a/modules/sdk-coin-stx/test/unit/stx.ts +++ b/modules/sdk-coin-stx/test/unit/stx.ts @@ -456,6 +456,23 @@ describe('STX:', function () { .verifyTransaction({ txPrebuild, txParams }) .should.be.rejectedWith('Tx memo does not match with expected txParams recipient memo'); }); + + it('should derive the memo from the recipient address memo id when no memo is passed', async function () { + const wallet = new Wallet(bitgo, basecoin, {}); + const txParams = { + recipients: [ + { + address: `${testData.txExplainedTransfer.recipient}?memoId=${encodeURIComponent( + testData.txExplainedTransfer.memo + )}`, + amount: testData.txExplainedTransfer.outputAmount, + }, + ], + wallet, + }; + const result = await basecoin.verifyTransaction({ txPrebuild, txParams }); + result.should.equal(true); + }); }); describe('Verify sBTC Withdraw Transaction', function () { @@ -538,6 +555,20 @@ describe('STX:', function () { .verifyTransaction({ txPrebuild: { txHex }, txParams }) .should.be.rejectedWith(/sBTC withdrawal btcAddress does not match/); }); + + it('should reject verifying withdrawal params against a non-withdrawal transaction', async function () { + const txParams = { + sbtcWithdrawParams: { + amount: '1000', + btcAddress: '1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2', + maxFee: '10000', + }, + recipients: [{ address: 'SM1K9VF5GN48Q0AC2C7SB8WM5N5NR6DYC9VM3QEJE', amount: '10' }], + }; + await basecoin + .verifyTransaction({ txPrebuild: { txHex: resources.RAW_TX_UNSIGNED }, txParams }) + .should.be.rejectedWith(/Tx is not a valid sBTC withdrawal transaction/); + }); }); describe('Recover Transaction STX', function () { @@ -692,5 +723,51 @@ describe('STX:', function () { }; await basecoin.recover(recoveryOptions).should.rejectedWith(RecoveryProviderError); }); + + it('should include a memo when recovering to a destination with a memo id', async function () { + const rootAddress = testData.HOT_WALLET_ROOT_ADDRESS; + nock(`https://api.testnet.hiro.so`) + .get(`/extended/v2/addresses/${rootAddress}/balances/stx`) + .reply(200, testData.ACCOUNT_BALANCE_RESPONSE); + nock(`https://api.testnet.hiro.so`) + .get(`/extended/v1/address/${rootAddress}/nonces`) + .reply(200, testData.ACCOUNT_NONCE_RESPONSE); + nock(`https://api.testnet.hiro.so`, { allowUnmocked: true }) + .post(`/v2/fees/transaction`) + .reply(200, testData.FEE_ESTIMATION_RESPONSE); + + const recoveryOptions: RecoveryOptions = { + backupKey: testData.HOT_WALLET_KEY_CARD_INFO.BACKUP_KEY, + userKey: testData.HOT_WALLET_KEY_CARD_INFO.USER_KEY, + rootAddress: rootAddress, + recoveryDestination: `${testData.DESTINATION_ADDRESS_WRW}?memoId=8`, + bitgoKey: testData.HOT_WALLET_KEY_CARD_INFO.BITGO_PUB_KEY, + walletPassphrase: testData.HOT_WALLET_KEY_CARD_INFO.WALLET_PASSPHRASE, + }; + const response: RecoveryTransaction = await basecoin.recover(recoveryOptions); + response.should.have.property('txHex'); + }); + + it('should reject recover when the backup key is not a private key', async function () { + const rootAddress = testData.HOT_WALLET_ROOT_ADDRESS; + nock(`https://api.testnet.hiro.so`) + .get(`/extended/v2/addresses/${rootAddress}/balances/stx`) + .reply(200, testData.ACCOUNT_BALANCE_RESPONSE); + nock(`https://api.testnet.hiro.so`) + .get(`/extended/v1/address/${rootAddress}/nonces`) + .reply(200, testData.ACCOUNT_NONCE_RESPONSE); + nock(`https://api.testnet.hiro.so`, { allowUnmocked: true }) + .post(`/v2/fees/transaction`) + .reply(200, testData.FEE_ESTIMATION_RESPONSE); + + const recoveryOptions: RecoveryOptions = { + backupKey: testData.COLD_WALLET_PUBLIC_KEY_INFO.BACKUP_KEY, + userKey: resources.xprv1, + rootAddress: rootAddress, + recoveryDestination: testData.DESTINATION_ADDRESS_WRW, + bitgoKey: testData.HOT_WALLET_KEY_CARD_INFO.BITGO_PUB_KEY, + }; + await basecoin.recover(recoveryOptions).should.be.rejectedWith('backupKey is not a private key'); + }); }); }); diff --git a/modules/sdk-coin-stx/test/unit/stxNodeErrors.ts b/modules/sdk-coin-stx/test/unit/stxNodeErrors.ts new file mode 100644 index 00000000000..be5216c9c1c --- /dev/null +++ b/modules/sdk-coin-stx/test/unit/stxNodeErrors.ts @@ -0,0 +1,161 @@ +import assert from 'assert'; + +import { BitGoAPI } from '@bitgo/sdk-api'; +import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test'; + +import { Stx } from '../../src'; + +type GetStub = (url: string) => Promise<{ statusCode: number; body: unknown }>; +type PostStub = (url: string) => { send: (body: unknown) => Promise<{ statusCode: number; body: unknown }> }; + +describe('Stx node request error handling', function () { + let bitgo: TestBitGoAPI; + let basecoin: Stx; + const address = 'STB44HYPYAT2BB2QE513NSP81HTMYWBJP02HPGK6'; + + const call = (method: string, arg: unknown): Promise => + (basecoin as unknown as Record Promise>)[method](arg); + + const stubBitgo = (get: GetStub, post: PostStub) => { + const bg = (basecoin as unknown as { bitgo: { get: unknown; post: unknown } }).bitgo; + const originalGet = bg.get; + const originalPost = bg.post; + bg.get = get; + bg.post = post; + return () => { + bg.get = originalGet; + bg.post = originalPost; + }; + }; + + const errorStubs = () => + stubBitgo( + async () => ({ statusCode: 500, body: {} }), + () => ({ send: async () => ({ statusCode: 500, body: {} }) }) + ); + + before(function () { + bitgo = TestBitGo.decorate(BitGoAPI, { env: 'mock' }); + bitgo.initializeTestVars(); + bitgo.safeRegister('stx', Stx.createInstance); + basecoin = bitgo.coin('stx') as Stx; + }); + + it('wraps a failed native stx balance request', async function () { + const restore = errorStubs(); + try { + await assert.rejects( + call('getNativeStxBalanceFromNode', { address }), + /unable to get native stx balance from node/ + ); + } finally { + restore(); + } + }); + + it('wraps a failed fungible token balance request', async function () { + const restore = errorStubs(); + try { + await assert.rejects( + call('getSingleFungibleTokenBalanceFromNode', { + address, + assetId: 'ST000000000000000000002AMW42H.token::token', + }), + /unable to get native stx balance from node/ + ); + } finally { + restore(); + } + }); + + it('wraps a failed account nonce request', async function () { + const restore = errorStubs(); + try { + await assert.rejects(call('getAccountNonceFromNode', { address }), /unable to get account nonce from node/); + } finally { + restore(); + } + }); + + it('wraps a failed transaction fee estimation request', async function () { + const restore = stubBitgo( + async () => ({ statusCode: 200, body: {} }), + () => ({ send: async () => ({ statusCode: 500, body: {} }) }) + ); + try { + await assert.rejects( + call('getTransactionFeeEstimation', { txHex: '00', txHexLength: 1 }), + /unable to get transaction fee estimation/ + ); + } finally { + restore(); + } + }); + + it('rejects a fee estimation response with an unexpected number of estimations', async function () { + const restore = stubBitgo( + async () => ({ statusCode: 200, body: {} }), + () => ({ send: async () => ({ statusCode: 200, body: { estimations: [] } }) }) + ); + try { + await assert.rejects( + call('getTransactionFeeEstimation', { txHex: '00', txHexLength: 1 }), + /unable to get transaction fee estimation/ + ); + } finally { + restore(); + } + }); + + it('rejects an unsupported token contract when selecting a builder', function () { + const selectBuilder = basecoin as unknown as { + getTokenOrNativeTransferBuilder: (contractAddress?: string, contractName?: string) => unknown; + }; + assert.throws( + () => selectBuilder.getTokenOrNativeTransferBuilder('ST000000000000000000002AMW42H', 'not-a-token'), + /invalid contract address or contract name, not supported/ + ); + }); + + it('rejects building a token transfer for an unsupported contract', async function () { + const build = basecoin as unknown as { buildTokenTransferTransaction: (params: unknown) => Promise }; + await assert.rejects( + build.buildTokenTransferTransaction({ + builder: {}, + contractAddress: 'ST000000000000000000002AMW42H', + contractName: 'not-a-token', + pubs: [], + nonce: 0, + rootAddressDetails: { address }, + destinationAddressDetails: { address }, + stxBalance: '0', + }), + /invalid contract address or contract name, not supported/ + ); + }); + + it('rejects building a token transfer with no recoverable token balance', async function () { + const restore = stubBitgo( + async () => ({ statusCode: 200, body: { balance: '0' } }), + () => ({ send: async () => ({ statusCode: 200, body: {} }) }) + ); + const build = basecoin as unknown as { buildTokenTransferTransaction: (params: unknown) => Promise }; + try { + await assert.rejects( + build.buildTokenTransferTransaction({ + builder: {}, + contractAddress: 'STAG18E45W613FZ3H4ZMF6QHH426EXM5QTSAVWYH', + contractName: 'tsip6dp-token', + pubs: [], + nonce: 0, + rootAddressDetails: { address }, + destinationAddressDetails: { address }, + stxBalance: '0', + }), + /no token balance found to recover/ + ); + } finally { + restore(); + } + }); +}); diff --git a/modules/sdk-coin-stx/test/unit/transaction.ts b/modules/sdk-coin-stx/test/unit/transaction.ts index f00560d710d..0375eb4dfec 100644 --- a/modules/sdk-coin-stx/test/unit/transaction.ts +++ b/modules/sdk-coin-stx/test/unit/transaction.ts @@ -1,6 +1,7 @@ import assert from 'assert'; import should from 'should'; import { coins } from '@bitgo/statics'; +import { createCoinbasePayload, deserializeTransaction } from '@stacks/transactions'; import { StxLib } from '../../src'; import * as testData from './resources'; @@ -48,4 +49,30 @@ describe('Stx Transaction', () => { should.equal(tx.transactionSize(), testData.RAW_TX_UNSIGNED.length / 2); }); }); + + describe('error branches', function () { + it('rejects signing with a public-only key pair', async function () { + const tx = new Transaction(coin); + const publicOnly = new KeyPair({ pub: testData.xpub1 }); + await tx.sign([publicOnly], 'sighash').should.be.rejectedWith(/Missing private key/); + }); + + it('rejects signing with missing signatures', async function () { + const tx = new Transaction(coin); + await tx.signWithSignatures(undefined as never, false).should.be.rejectedWith(/Missing signatures/); + }); + + it('rejects an unsupported payload type on toJson', function () { + const tx = new Transaction(coin); + const wire = deserializeTransaction(testData.RAW_TX_UNSIGNED); + (wire as unknown as { payload: unknown }).payload = createCoinbasePayload(new Uint8Array(32)); + tx.stxTransaction = wire; + assert.throws(() => tx.toJson(), /payload type not supported/); + }); + + it('wraps a malformed raw transaction on fromRawTransaction', function () { + const tx = new Transaction(coin); + assert.throws(() => tx.fromRawTransaction('deadbeef'), /Error parsing the raw transaction/); + }); + }); }); diff --git a/modules/sdk-coin-stx/test/unit/transactionBuilder/builderValidators.ts b/modules/sdk-coin-stx/test/unit/transactionBuilder/builderValidators.ts new file mode 100644 index 00000000000..6cd991fc27b --- /dev/null +++ b/modules/sdk-coin-stx/test/unit/transactionBuilder/builderValidators.ts @@ -0,0 +1,105 @@ +import assert from 'assert'; + +import { coins } from '@bitgo/statics'; + +import { StxLib } from '../../../src'; +import * as testData from '../resources'; + +describe('Stx TransactionBuilder validators', function () { + const factory = new StxLib.TransactionBuilderFactory(coins.get('tstx')); + const validAddress = 'STB44HYPYAT2BB2QE513NSP81HTMYWBJP02HPGK6'; + const pubKey = '02f6d0597fb6d5467203d080e17f7b4f767ead59fc303b7d7261a832cb44305bb0'; + + it('rejects an empty raw transaction', function () { + assert.throws(() => factory.getTransferBuilder().from(''), /Raw transaction is empty/); + }); + + it('wraps a malformed raw transaction in a parse error', function () { + assert.throws( + () => factory.getTransferBuilder().from('deadbeef'), + /There was an error parsing the raw transaction/ + ); + }); + + it('rejects an invalid public key', function () { + assert.throws(() => factory.getTransferBuilder().fromPubKey('not-a-public-key'), /Invalid public key/); + }); + + it('rejects an invalid recipient address', function () { + assert.throws(() => factory.getTransferBuilder().to('not-an-address'), /Invalid address/); + }); + + it('rejects an invalid address on validateAddress', function () { + assert.throws(() => factory.getTransferBuilder().validateAddress({ address: 'not-an-address' }), /Invalid address/); + }); + + it('rejects negative fee and signature values', function () { + assert.throws(() => factory.getTransferBuilder().fee({ fee: '-1' }), /Value cannot be less than zero/); + assert.throws(() => factory.getTransferBuilder().numberSignatures(-1), /Value cannot be less than zero/); + }); + + it('rejects an invalid key pair on validateKey', function () { + assert.throws(() => factory.getTransferBuilder().validateKey({ key: 'invalid' }), /Unsupported private key/); + }); + + it('rejects building a transaction without a fee', async function () { + const builder = factory.getTransferBuilder(); + builder.to(validAddress); + builder.amount('100'); + builder.fromPubKey(pubKey); + builder.numberSignatures(1); + builder.nonce(1); + await assert.rejects(builder.build(), /Invalid transaction: missing fee/); + }); + + it('rejects building a transaction without a nonce', async function () { + const builder = factory.getTransferBuilder(); + builder.fee({ fee: '180' }); + builder.to(validAddress); + builder.amount('100'); + builder.fromPubKey(pubKey); + builder.numberSignatures(1); + await assert.rejects(builder.build(), /Invalid transaction: missing nonce/); + }); + + it('rejects signing twice with the same key', function () { + const builder = factory.getTransferBuilder(); + builder.fee({ fee: '180' }); + builder.to(validAddress); + builder.amount('100'); + builder.nonce(1); + builder.numberSignatures(1); + builder.sign({ key: testData.secretKey1 }); + assert.throws(() => builder.sign({ key: testData.secretKey1 }), /Repeated sign/); + }); + + it('rejects a transfer init with a missing or non-transfer payload', function () { + const builder = factory.getTransferBuilder(); + assert.throws( + () => builder.initBuilder({ toJson: () => ({ payload: undefined }) } as never), + /payload must not be undefined/ + ); + assert.throws( + () => builder.initBuilder({ toJson: () => ({ payload: { payloadType: 2 } }) } as never), + /Transaction should be transfer/ + ); + }); + + it('rejects a transaction without a spending condition', function () { + const builder = factory.getTransferBuilder(); + const fakeTx = { + toJson: () => ({ payload: { payloadType: 0, to: validAddress, amount: '100' } }), + stxTransaction: { auth: {} }, + }; + assert.throws(() => builder.initBuilder(fakeTx as never), /spending condition cannot be undefined/); + }); + + it('rejects building a transfer without a public key', async function () { + const builder = factory.getTransferBuilder(); + builder.fee({ fee: '180' }); + builder.nonce(1); + builder.to(validAddress); + builder.amount('100'); + await assert.rejects(builder.build(), /supply at least 1 public key/); + }); +}); diff --git a/modules/sdk-coin-stx/test/unit/transactionBuilder/contractBuilder.ts b/modules/sdk-coin-stx/test/unit/transactionBuilder/contractBuilder.ts index f59d4e50add..dece7d9c268 100644 --- a/modules/sdk-coin-stx/test/unit/transactionBuilder/contractBuilder.ts +++ b/modules/sdk-coin-stx/test/unit/transactionBuilder/contractBuilder.ts @@ -500,6 +500,49 @@ describe('Stacks: Contract Builder', function () { ); }); }); + + describe('contract builder validation and init edge cases', function () { + it('rejects an invalid contract address and empty names', function () { + const builder = factory.getContractBuilder(); + assert.throws(() => builder.contractAddress('invalid'), /Invalid address/); + assert.throws(() => builder.contractName(''), /Invalid name/); + assert.throws(() => builder.functionName(''), /Invalid name/); + }); + + it('parses a response-error Clarity value descriptor', function () { + const builder = factory.getContractBuilder(); + builder.functionArgs([{ type: 'response', val: { type: 'err', val: { type: 'uint128', val: '1' } } }]); + }); + + it('rejects principal descriptors without a string val', function () { + const builder = factory.getContractBuilder(); + assert.throws( + () => builder.functionArgs([{ type: 'contractPrincipal', val: 1 } as never]), + /contract principal requires string val/ + ); + assert.throws( + () => builder.functionArgs([{ type: 'standardPrincipal', val: 1 } as never]), + /standard principal requires string val/ + ); + }); + + it('rejects a contract-call init with a missing or non-contract-call payload', function () { + const builder = factory.getContractBuilder(); + assert.throws( + () => builder.initBuilder({ toJson: () => ({ payload: undefined }) } as never), + /payload must not be undefined/ + ); + assert.throws( + () => builder.initBuilder({ toJson: () => ({ payload: { payloadType: 0 } }) } as never), + /Transaction should be contract call/ + ); + }); + + it('rejects building a contract call without a public key', async function () { + const builder = initTxBuilder(); + await assert.rejects(builder.build(), /supply at least 1 public key/); + }); + }); }); }); }); diff --git a/modules/sdk-coin-stx/test/unit/transactionBuilder/fungibleTokenTransferBuilder.ts b/modules/sdk-coin-stx/test/unit/transactionBuilder/fungibleTokenTransferBuilder.ts index f45fff269a1..39a8a26e7e9 100644 --- a/modules/sdk-coin-stx/test/unit/transactionBuilder/fungibleTokenTransferBuilder.ts +++ b/modules/sdk-coin-stx/test/unit/transactionBuilder/fungibleTokenTransferBuilder.ts @@ -179,5 +179,100 @@ describe('Stacks: Fungible Token Transfer Builder', () => { ); }); }); + + describe('fungible token transfer builder validation and mainnet post-condition', function () { + it('rejects empty contract name and function name', function () { + const builder = factory.getFungibleTokenTransferBuilder(); + assert.throws(() => builder.contractName(''), /Invalid name/); + assert.throws(() => builder.functionName(''), /Invalid name/); + }); + + it('rejects fewer than three function args', function () { + const builder = factory.getFungibleTokenTransferBuilder(); + assert.throws(() => builder.functionArgs([uintCV(1), uintCV(2)]), /Invalid number of arguments/); + }); + + it('builds a mainnet fungible token transfer with a mainnet post-condition address', async function () { + const builder = factoryProd.getFungibleTokenTransferBuilder(); + builder.fee({ fee: '180' }); + builder.nonce(0); + builder.contractAddress(testData.FUNGIBLE_TOKEN_TRANSFER_CONSTANTS.CONTRACT_ADDRESS); + builder.contractName(testData.FUNGIBLE_TOKEN_TRANSFER_CONSTANTS.CONTRACT_NAME); + builder.functionName(testData.FUNGIBLE_TOKEN_TRANSFER_CONSTANTS.FUNCTION_NAME); + builder.tokenName(testData.FUNGIBLE_TOKEN_TRANSFER_CONSTANTS.TOKEN_NAME); + builder.functionArgs([ + uintCV('10000'), + standardPrincipalCV(testData.FUNGIBLE_TOKEN_TRANSFER_CONSTANTS.SENDER_ADDRESS), + standardPrincipalCV(testData.FUNGIBLE_TOKEN_TRANSFER_CONSTANTS.RECEIVER_ADDRESS), + ]); + builder.fromPubKey(testData.TX_SENDER.pub); + builder.numberSignatures(1); + const tx = await builder.build(); + should.exist(tx.toBroadcastFormat()); + }); + + it('fails the build for a contract that is not a known sip10 token', async function () { + const builder = factory.getFungibleTokenTransferBuilder(); + builder.fee({ fee: '180' }); + builder.nonce(0); + builder.contractAddress('ST000000000000000000002AMW42H'); + builder.contractName('not-a-token'); + builder.functionName('transfer'); + builder.functionArgs([ + uintCV('10000'), + standardPrincipalCV(testData.FUNGIBLE_TOKEN_TRANSFER_CONSTANTS.SENDER_ADDRESS), + standardPrincipalCV(testData.FUNGIBLE_TOKEN_TRANSFER_CONSTANTS.RECEIVER_ADDRESS), + ]); + builder.fromPubKey(testData.TX_SENDER.pub); + builder.numberSignatures(1); + // must fail at build time rather than emit an 'undefined' asset name that aborts on-chain + await assert.rejects(builder.build(), /unknown fungible token contract/); + }); + }); + + describe('sBTC token transfer', function () { + const sbtcContracts = [ + ['tstx:tsbtc', 'ST1F7QA2MDF17S807EPA36TSS8AMEFY4KA9TVGWXT'], + ['stx:sbtc', 'SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4'], + ] as const; + + for (const [coinName, contractAddress] of sbtcContracts) { + it(`builds a ${coinName} transfer with a statics-resolved sbtc-token post-condition`, async function () { + const sbtcFactory = new StxLib.TransactionBuilderFactory(coins.get(coinName)); + const builder = sbtcFactory.getFungibleTokenTransferBuilder(); + builder.fee({ fee: '180' }); + builder.nonce(0); + builder.contractAddress(contractAddress); + builder.contractName('sbtc-token'); + builder.functionName('transfer'); + builder.functionArgs([ + uintCV('10000'), + standardPrincipalCV(testData.FUNGIBLE_TOKEN_TRANSFER_CONSTANTS.SENDER_ADDRESS), + standardPrincipalCV(testData.FUNGIBLE_TOKEN_TRANSFER_CONSTANTS.RECEIVER_ADDRESS), + ]); + builder.fromPubKey(testData.TX_SENDER.pub); + builder.numberSignatures(1); + + const tx = await builder.build(); + + // no explicit tokenName was set, so it must be resolved from statics + const payload = (tx as Transaction).stxTransaction.payload as { contractName: { content: string } }; + should.equal(payload.contractName.content, 'sbtc-token'); + + const postCondition = (tx as Transaction).stxTransaction.postConditions.values[0] as unknown as { + asset: { contractName: { content: string }; assetName: { content: string } }; + }; + should.equal(postCondition.asset.contractName.content, 'sbtc-token'); + should.equal(postCondition.asset.assetName.content, 'sbtc-token'); + + // rebuild from the serialized tx must be byte-identical (post-conditions preserved). + // An unsigned single-sig tx carries no pubkey in its auth fields, so it is re-supplied + // here — same pattern as the PoX-5/contract rebuild tests. + const rebuilt = sbtcFactory.from(tx.toBroadcastFormat()); + rebuilt.fromPubKey(testData.TX_SENDER.pub); + should.equal((await rebuilt.build()).toBroadcastFormat(), tx.toBroadcastFormat()); + }); + } + }); }); }); diff --git a/modules/sdk-coin-stx/test/unit/transactionBuilder/pox5Builder.ts b/modules/sdk-coin-stx/test/unit/transactionBuilder/pox5Builder.ts index da45869727d..149594270be 100644 --- a/modules/sdk-coin-stx/test/unit/transactionBuilder/pox5Builder.ts +++ b/modules/sdk-coin-stx/test/unit/transactionBuilder/pox5Builder.ts @@ -568,4 +568,30 @@ describe('Stacks: PoX-5 Builder', function () { ); }); }); + + describe('calculateRewards and claimStakerRewardsForSigner', () => { + it('builds calculate-rewards with a list of bond indices', async () => { + const builder = configure(factory.getPox5Builder()); + builder.calculateRewards([1, 2]); + const txJson = (await builder.build()).toJson(); + should.exist(txJson.payload); + }); + + it('builds claim-staker-rewards for a signer with and without a bond index', async () => { + const withBondIndex = configure(factory.getPox5Builder()); + withBondIndex.claimStakerRewardsForSigner({ + staker: testData.TX_SENDER.address, + rewardCycle: 1, + bondIndex: 2, + }); + should.exist((await withBondIndex.build()).toBroadcastFormat()); + + const withoutBondIndex = configure(factory.getPox5Builder()); + withoutBondIndex.claimStakerRewardsForSigner({ + staker: testData.TX_SENDER.address, + rewardCycle: 1, + }); + should.exist((await withoutBondIndex.build()).toBroadcastFormat()); + }); + }); }); diff --git a/modules/sdk-coin-stx/test/unit/transactionBuilder/sbtcWithdrawBuilder.ts b/modules/sdk-coin-stx/test/unit/transactionBuilder/sbtcWithdrawBuilder.ts index 5122ab8f995..be02b2ab5e6 100644 --- a/modules/sdk-coin-stx/test/unit/transactionBuilder/sbtcWithdrawBuilder.ts +++ b/modules/sdk-coin-stx/test/unit/transactionBuilder/sbtcWithdrawBuilder.ts @@ -1,5 +1,16 @@ import should from 'should'; -import { ClarityType, ContractCallPayload, privateKeyToPublic, publicKeyToHex } from '@stacks/transactions'; +import { + bufferCV, + bufferCVFromString, + ClarityType, + ClarityValue, + ContractCallPayload, + deserializeTransaction, + privateKeyToPublic, + publicKeyToHex, + tupleCV, + uintCV, +} from '@stacks/transactions'; import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test'; import { BitGoAPI } from '@bitgo/sdk-api'; @@ -321,5 +332,90 @@ describe('Stacks: sBTC Withdraw Builder', function () { await builder.build().should.be.rejectedWith(/Withdrawal params are not set/); }); }); + + describe('sBTC withdraw deserialization edge cases', function () { + const btcAddress = '1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2'; + const recipientTuple = () => + tupleCV({ version: bufferCV(Buffer.from([0])), hashbytes: bufferCV(Buffer.alloc(32)) }); + + const buildWithdrawTx = async () => { + const builder = initTxBuilder(); + const pubKeyStrings = testData.prvKeysString.map((key) => publicKeyToHex(privateKeyToPublic(key))); + builder.fromPubKey(pubKeyStrings); + builder.numberSignatures(2); + builder.withdraw({ amount: '100000', btcAddress, maxFee: '5000' }); + return deserializeTransaction((await builder.build()).toBroadcastFormat()); + }; + + const asTransaction = (wire: ReturnType, args?: ClarityValue[]) => { + if (args) { + (wire.payload as unknown as { functionArgs: ClarityValue[] }).functionArgs = args; + } + const tx = new StxLib.Transaction(coins.get(coinNameTest)); + tx.stxTransaction = wire; + return tx; + }; + + it('returns undefined withdraw params when none are set', function () { + should.not.exist(factory.getSbtcWithdrawBuilder().getWithdrawParams()); + }); + + it('rejects a serialized withdrawal with an unexpected number of args', async function () { + const tx = asTransaction(await buildWithdrawTx(), [uintCV(1), uintCV(2)]); + should.throws(() => factory.getSbtcWithdrawBuilder(tx), /Invalid number of function args for sBTC withdrawal/); + }); + + it('rejects a serialized withdrawal whose amount is not a uint', async function () { + const tx = asTransaction(await buildWithdrawTx(), [bufferCVFromString('x'), recipientTuple(), uintCV(3)]); + should.throws(() => factory.getSbtcWithdrawBuilder(tx), /Expected uint for amount argument/); + }); + + it('rejects a serialized withdrawal whose recipient is not a tuple', async function () { + const tx = asTransaction(await buildWithdrawTx(), [uintCV(1), uintCV(2), uintCV(3)]); + should.throws(() => factory.getSbtcWithdrawBuilder(tx), /Expected tuple for recipient argument/); + }); + + it('rejects a serialized withdrawal with non-buffer recipient fields', async function () { + const tx = asTransaction(await buildWithdrawTx(), [ + uintCV(1), + tupleCV({ version: uintCV(1), hashbytes: uintCV(2) }), + uintCV(3), + ]); + should.throws(() => factory.getSbtcWithdrawBuilder(tx), /Expected buffer fields in recipient tuple/); + }); + + it('rejects a serialized withdrawal whose max fee is not a uint', async function () { + const tx = asTransaction(await buildWithdrawTx(), [uintCV(1), recipientTuple(), bufferCVFromString('x')]); + should.throws(() => factory.getSbtcWithdrawBuilder(tx), /Expected uint for max-fee argument/); + }); + + it('returns undefined when the stored payload has no recipient tuple or buffers', function () { + const builder = factory.getSbtcWithdrawBuilder() as unknown as { + _withdrawParams: unknown; + _transaction: unknown; + getWithdrawParams: () => unknown; + }; + builder._withdrawParams = { amount: '1', btcAddress: '', maxFee: '1' }; + const stub = (functionArgs: unknown[]) => ({ stxTransaction: { payload: { functionArgs } } }); + + builder._transaction = stub([uintCV(1), uintCV(2), uintCV(3)]); + should.not.exist(builder.getWithdrawParams()); + + builder._transaction = stub([uintCV(1), tupleCV({ version: uintCV(1), hashbytes: uintCV(1) }), uintCV(3)]); + should.not.exist(builder.getWithdrawParams()); + }); + + it('builds a mainnet withdrawal with a mainnet post-condition address', async function () { + const mainnetFactory = new StxLib.TransactionBuilderFactory(coins.get('stx')); + const builder = mainnetFactory.getSbtcWithdrawBuilder(); + builder.fee({ fee: '1000' }); + builder.nonce(1); + const pubKeyStrings = testData.prvKeysString.map((key) => publicKeyToHex(privateKeyToPublic(key))); + builder.fromPubKey(pubKeyStrings); + builder.numberSignatures(2); + builder.withdraw({ amount: '100000', btcAddress, maxFee: '5000' }); + should.exist((await builder.build()).toBroadcastFormat()); + }); + }); }); }); diff --git a/modules/sdk-coin-stx/test/unit/transactionBuilder/sendmanyBuilder.ts b/modules/sdk-coin-stx/test/unit/transactionBuilder/sendmanyBuilder.ts index e235a428330..d72b21c5721 100644 --- a/modules/sdk-coin-stx/test/unit/transactionBuilder/sendmanyBuilder.ts +++ b/modules/sdk-coin-stx/test/unit/transactionBuilder/sendmanyBuilder.ts @@ -1,3 +1,4 @@ +import assert from 'assert'; import should from 'should'; import { STACKS_TESTNET, STACKS_MAINNET } from '@stacks/network'; import { cvToString, privateKeyToPublic, publicKeyToHex } from '@stacks/transactions'; @@ -111,5 +112,32 @@ describe('Stacks: Send Many Builder', function () { tx.toBroadcastFormat().should.equal(testData.SIGNED_SEND_MANY_WITH_MEMO); }); }); + + describe('send-many builder validation and mainnet post-condition', () => { + const destinationAddress = 'SP10FDHQQ4F2F0KHMN6Z24RMAMGX5933SQJCWKAAR'; + + it('rejects a send with an invalid address, amount or memo', () => { + const builder = initTxBuilder(); + assert.throws(() => builder.send({ address: '', amount: '100' }), /Invalid or missing address/); + assert.throws(() => builder.send({ address: 'invalid', amount: '100' }), /Invalid or missing address/); + assert.throws(() => builder.send({ address: destinationAddress, amount: '' }), /Invalid or missing amount/); + assert.throws(() => builder.send({ address: destinationAddress, amount: '-5' }), /Invalid or missing amount/); + assert.throws( + () => builder.send({ address: destinationAddress, amount: '100', memo: 'x'.repeat(200) }), + /Invalid memo/ + ); + }); + + it('builds a mainnet multisig send-many with a mainnet multisig post-condition', async () => { + const builder = factoryProd.getSendmanyBuilder(); + builder.fee({ fee: '532' }); + builder.nonce(1); + builder.fromPubKey([testData.pubKey1, testData.pubKey2]); + builder.numberSignatures(2); + builder.send({ address: destinationAddress, amount: '100' }); + const tx = await builder.build(); + should.exist(tx.toBroadcastFormat()); + }); + }); }); }); diff --git a/modules/sdk-coin-stx/test/unit/transactionBuilder/transferBuilder.ts b/modules/sdk-coin-stx/test/unit/transactionBuilder/transferBuilder.ts index 6191f40c5e0..0f620f92ab8 100644 --- a/modules/sdk-coin-stx/test/unit/transactionBuilder/transferBuilder.ts +++ b/modules/sdk-coin-stx/test/unit/transactionBuilder/transferBuilder.ts @@ -205,6 +205,89 @@ describe('Stacks: Send Many Builder', function () { should.deepEqual(tx.toBroadcastFormat(), testData.MULTI_SIG_SIGNED_TRANSACTION); }); + it('builds an uncompressed-key multisig transfer byte-identically to the v2 fixture', async () => { + // Pins the appendOrigin encoding for uncompressed keys against a v2.0.1-generated fixture: + // the key is appended as given (uncompressed tag) while the serialized key stays 33 bytes. + const uncompressedPubs = [testData.prv1, testData.prv2, testData.prv3].map( + (prv) => new KeyPair({ prv }).getKeys().pub + ); + uncompressedPubs.forEach((pub) => should.equal(pub.length, 130)); + + const unsignedBuilder = initTxBuilder(); + unsignedBuilder.fromPubKey(uncompressedPubs); + unsignedBuilder.numberSignatures(2); + const unsigned = await unsignedBuilder.build(); + should.deepEqual(unsigned.toBroadcastFormat(), testData.UNCOMPRESSED_MULTISIG_UNSIGNED_TRANSFER); + + const signingBuilder = factory.getTransferBuilder(); + signingBuilder.from(unsigned.toBroadcastFormat()); + signingBuilder.sign({ key: testData.prv1 }); + signingBuilder.sign({ key: testData.prv2 }); + signingBuilder.fromPubKey(uncompressedPubs); + const signed = await signingBuilder.build(); + should.deepEqual(signed.toBroadcastFormat(), testData.UNCOMPRESSED_MULTISIG_SIGNED_TRANSFER); + should.deepEqual(signed.signature.length, 2); + }); + + it('re-derives the sender from compressed keys when rebuilding an uncompressed-key multisig tx', async () => { + // Known limitation, pre-existing (v2 behaves the same — its deserialized signature fields + // recover the compressed key and the appended origin field deserializes to a 33-byte key, so + // `initBuilder` always repopulates compressed pubkeys). A `from(hex)` rebuild therefore + // re-derives the sender from the compressed keys and is not byte-identical to the original + // uncompressed-key tx. The fresh build path above is unaffected. + const rebuiltBuilder = factory.from(testData.UNCOMPRESSED_MULTISIG_SIGNED_TRANSFER); + const rebuilt = await rebuiltBuilder.build(); + const rebuiltSender = ( + rebuilt as never as { stxTransaction: { auth: { spendingCondition: { signer: string } } } } + ).stxTransaction.auth.spendingCondition.signer; + should.equal(rebuiltSender, '2fe507c09dbb23c3b7e5d166c81fc4b87692510b'); + should.notEqual(rebuilt.toBroadcastFormat(), testData.UNCOMPRESSED_MULTISIG_SIGNED_TRANSFER); + }); + + it('a compressed-key multisig tx verifies and round-trips byte-identically', async () => { + // The standard path: compressed keys make the signer hash match the keys recoverable from the + // auth fields, so the tx verifies and the `from(hex)` rebuild is byte-identical. + const builder = initTxBuilder(); + builder.memo('test memo'); + builder.sign({ key: testData.prv1 }); + builder.sign({ key: testData.prv2 }); + builder.fromPubKey([testData.pub1, testData.pub2, testData.pub3]); + builder.numberSignatures(2); + const tx = await builder.build(); + should.deepEqual(tx.toBroadcastFormat(), testData.MULTI_SIG_SIGNED_TRANSACTION); + const verify = (t: unknown) => + (t as never as { stxTransaction: { verifyOrigin: () => string } }).stxTransaction.verifyOrigin(); + should.exist(verify(tx)); + + const rebuilt = await factory.from(tx.toBroadcastFormat()).build(); + should.deepEqual(rebuilt.toBroadcastFormat(), testData.MULTI_SIG_SIGNED_TRANSACTION); + should.equal(verify(rebuilt), verify(tx)); + }); + + it('does not verify an uncompressed-key multisig tx (pre-existing, v2-identical)', async () => { + // The signer hash is derived from the uncompressed keys, but the auth fields only carry the + // compressed key bytes, so a re-derivation from the tx's own fields can never match the + // signer — such txs are invalid by construction (stacks.js v2 behaves the same). Pinned so + // the limitation is explicit instead of assumed working. + const uncompressedPubs = [testData.prv1, testData.prv2, testData.prv3].map( + (prv) => new KeyPair({ prv }).getKeys().pub + ); + const builder = factory.getTransferBuilder(); + builder.fee({ fee: '180' }); + builder.nonce(0); + builder.to(testData.TX_RECIEVER.address); + builder.amount('1000'); + builder.sign({ key: testData.prv1 }); + builder.sign({ key: testData.prv2 }); + builder.fromPubKey(uncompressedPubs); + builder.numberSignatures(2); + const tx = await builder.build(); + should.throws( + () => (tx as never as { stxTransaction: { verifyOrigin: () => string } }).stxTransaction.verifyOrigin(), + /Signer hash does not equal hash of public key/ + ); + }); + it('a transfer transaction signed multiple times', async () => { const builder = initTxBuilder(); builder.memo('test memo'); diff --git a/modules/sdk-coin-stx/test/unit/transactionBuilderFactory.ts b/modules/sdk-coin-stx/test/unit/transactionBuilderFactory.ts new file mode 100644 index 00000000000..293861bcc86 --- /dev/null +++ b/modules/sdk-coin-stx/test/unit/transactionBuilderFactory.ts @@ -0,0 +1,31 @@ +import assert from 'assert'; + +import { coins } from '@bitgo/statics'; +import { createCoinbasePayload, deserializeTransaction } from '@stacks/transactions'; + +import { StxLib } from '../../src'; +import * as testData from './resources'; + +describe('Stx TransactionBuilderFactory', function () { + const factory = new StxLib.TransactionBuilderFactory(coins.get('stx')); + + it('rejects an empty raw transaction', function () { + assert.throws(() => factory.from(''), /Raw transaction is empty/); + }); + + it('rejects a raw transaction that cannot be deserialized', function () { + assert.throws(() => factory.from('deadbeef'), /Invalid raw transaction/); + }); + + it('does not implement wallet initialization', function () { + assert.throws(() => factory.getWalletInitializationBuilder(), /method not implemented/); + }); + + it('rejects a transaction with an unsupported payload type', function () { + const tx = deserializeTransaction(testData.RAW_TX_UNSIGNED); + // replace the token-transfer payload with a coinbase payload (PayloadType.Coinbase is not handled) + (tx as unknown as { payload: unknown }).payload = createCoinbasePayload(new Uint8Array(32)); + const raw = tx.serialize(); + assert.throws(() => factory.from(raw), /There was an error parsing the raw transaction/); + }); +}); diff --git a/modules/sdk-coin-stx/test/unit/tstxsignet.ts b/modules/sdk-coin-stx/test/unit/tstxsignet.ts index 2854e438e1c..d82decfcc24 100644 --- a/modules/sdk-coin-stx/test/unit/tstxsignet.ts +++ b/modules/sdk-coin-stx/test/unit/tstxsignet.ts @@ -29,6 +29,17 @@ describe('tstxsignet', function () { assert.strictEqual(basecoin.getPublicNodeUrl(), 'https://api.staking-testnet.hiro.so'); }); + it('throws when the environment has no stx signet node url', function () { + const bitgoEnv = (basecoin as unknown as { bitgo: { getEnv: () => string } }).bitgo; + const originalGetEnv = bitgoEnv.getEnv; + bitgoEnv.getEnv = () => 'prod'; + try { + assert.throws(() => basecoin.getPublicNodeUrl(), /no stx signet node url configured for env prod/); + } finally { + bitgoEnv.getEnv = originalGetEnv; + } + }); + describe('wallet creation', function () { it('generates a wallet keypair from the coin', function () { const keyPair = basecoin.generateKeyPair(); diff --git a/modules/sdk-coin-stx/test/unit/util.ts b/modules/sdk-coin-stx/test/unit/util.ts index dd730d54b02..6434d279c7b 100644 --- a/modules/sdk-coin-stx/test/unit/util.ts +++ b/modules/sdk-coin-stx/test/unit/util.ts @@ -1,6 +1,7 @@ import assert from 'assert'; import should from 'should'; import { + AddressHashMode, AddressVersion, ClarityType, IntCV, @@ -10,6 +11,11 @@ import { SomeCV, UIntCV, ListCV, + bufferCV, + bufferCVFromString, + listCV, + standardPrincipalCV, + uintCV, } from '@stacks/transactions'; import BigNum from 'bn.js'; import * as testData from './resources'; @@ -445,4 +451,96 @@ describe('Stx util library', function () { ); }); }); + + describe('edge cases and error branches', function () { + const validAddress = 'STB44HYPYAT2BB2QE513NSP81HTMYWBJP02HPGK6'; + const compressedPubKey = '02f6d0597fb6d5467203d080e17f7b4f767ead59fc303b7d7261a832cb44305bb0'; + + it('should prefix a buffer with 0x', function () { + Utils.bufferToHexPrefixString(Buffer.from('deadbeef', 'hex')).should.equal('0xdeadbeef'); + }); + + it('should reject a contract principal whose address is invalid', function () { + assert.throws(() => Utils.contractPrincipalCVFromString('notanaddress.contract'), /must contain a valid address/); + }); + + it('should reject a raw transaction that is not a hex string', function () { + Utils.isValidRawTransaction(123).should.be.false(); + Utils.isValidRawTransaction('deadbeef').should.be.false(); + }); + + it('should reject a non-string memo', function () { + Utils.isValidMemo(123 as unknown as string).should.be.false(); + }); + + it('should reject more signatures than public keys', function () { + assert.throws( + () => + Utils.getSTXAddressFromPubKeys([compressedPubKey], AddressVersion.MainnetMultiSig, AddressHashMode.P2SH, 2), + /Number of signatures required must be lower or equal to the number of Public Keys/ + ); + }); + + it('should reject signing a message with a public-only key pair', function () { + const keyPair = new KeyPair({ pub: testData.xpub1 }); + assert.throws(() => Utils.signMessage(keyPair, 'message'), /Missing private key/); + }); + + it('should reject normalizing an invalid address or memo id', function () { + assert.throws(() => Utils.normalizeAddress({ address: 'invalid' }), /invalid address: invalid/); + assert.throws(() => Utils.normalizeAddress({ address: validAddress, memoId: '-1' }), /invalid memo id: -1/); + }); + + it('should reject an address whose query string has no memo id', function () { + assert.throws(() => Utils.getAddressDetails(`${validAddress}?notMemoId=1`), /invalid address with memo id/); + }); + + it('should reject malformed send-many-memo function args', function () { + assert.throws(() => Utils.functionArgsToSendParams([]), /don't match send-many-memo type declaration/); + assert.throws( + () => Utils.functionArgsToSendParams([listCV([uintCV(1)])]), + /don't match send-many-memo type declaration/ + ); + }); + + it('should reject malformed token transfer function args', function () { + assert.throws(() => Utils.functionArgsToTokenTransferParams([]), /don't match token transfer declaration/); + assert.throws( + () => + Utils.functionArgsToTokenTransferParams([ + uintCV(1), + bufferCVFromString('sender'), + bufferCVFromString('recipient'), + ]), + /don't match token transfer declaration/ + ); + }); + + it('should parse an optional memo in token transfer function args', function () { + const params = Utils.functionArgsToTokenTransferParams([ + uintCV(1000), + standardPrincipalCV(validAddress), + standardPrincipalCV(validAddress), + bufferCV(Buffer.from('memo', 'ascii')), + ]); + params.memo?.should.equal('memo'); + }); + + it('should resolve a supported sip10 token name and return undefined for an unsupported contract', function () { + should.equal( + Utils.findContractTokenNameUsingContract('STAG18E45W613FZ3H4ZMF6QHH426EXM5QTSAVWYH', 'tsip6dp-token'), + 'tsip6dp-token' + ); + should.equal( + Utils.findContractTokenNameUsingContract('SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4', 'sbtc-token'), + 'sbtc-token' + ); + should.equal( + Utils.findContractTokenNameUsingContract('ST1F7QA2MDF17S807EPA36TSS8AMEFY4KA9TVGWXT', 'sbtc-token'), + 'sbtc-token' + ); + should.equal(Utils.findContractTokenNameUsingContract('ST000000000000000000002AMW42H', 'not-a-token'), undefined); + should.equal(Utils.findTokenNameByContract('ST000000000000000000002AMW42H', 'not-a-token'), undefined); + }); + }); });