diff --git a/modules/sdk-coin-stx/package.json b/modules/sdk-coin-stx/package.json index d8591d6c36..38c75f1743 100644 --- a/modules/sdk-coin-stx/package.json +++ b/modules/sdk-coin-stx/package.json @@ -46,8 +46,10 @@ "@noble/curves": "1.8.1", "@stacks/network": "^4.3.0", "@stacks/transactions": "2.0.1", + "bech32": "^2.0.0", "bignumber.js": "^9.0.0", "bn.js": "^5.2.1", + "bs58check": "^2.1.2", "ethereumjs-util": "7.1.5", "lodash": "^4.18.0" }, diff --git a/modules/sdk-coin-stx/src/lib/btcAddressUtils.ts b/modules/sdk-coin-stx/src/lib/btcAddressUtils.ts new file mode 100644 index 0000000000..80e1be268a --- /dev/null +++ b/modules/sdk-coin-stx/src/lib/btcAddressUtils.ts @@ -0,0 +1,106 @@ +import * as bs58check from 'bs58check'; +import { bech32, bech32m } from 'bech32'; + +/** + * sBTC address version bytes as defined by the sBTC withdrawal contract. + */ +export enum SbtcAddressVersion { + P2PKH = 0x00, + P2SH = 0x01, + P2WPKH = 0x04, + P2WSH = 0x05, + P2TR = 0x06, +} + +interface DecodedBtcAddress { + version: SbtcAddressVersion; + hashBytes: Buffer; +} + +const BASE58_MAINNET_P2PKH = 0x00; +const BASE58_MAINNET_P2SH = 0x05; +const BASE58_TESTNET_P2PKH = 0x6f; +const BASE58_TESTNET_P2SH = 0xc4; + +/** + * Decode a Bitcoin address into an sBTC version byte and hash bytes. + * + * @param {string} address - A Bitcoin address (P2PKH, P2SH, P2WPKH, P2WSH, or P2TR) + * @returns {DecodedBtcAddress} The sBTC version and raw hash bytes + */ +export function decodeBtcAddress(address: string): DecodedBtcAddress { + // Try base58check first (P2PKH / P2SH) + try { + const decoded = bs58check.decode(address); + const versionByte = decoded[0]; + const hash = decoded.slice(1); + + if (hash.length !== 20) { + throw new Error(`Invalid base58check hash length: ${hash.length}`); + } + + switch (versionByte) { + case BASE58_MAINNET_P2PKH: + case BASE58_TESTNET_P2PKH: + return { version: SbtcAddressVersion.P2PKH, hashBytes: Buffer.from(hash) }; + case BASE58_MAINNET_P2SH: + case BASE58_TESTNET_P2SH: + return { version: SbtcAddressVersion.P2SH, hashBytes: Buffer.from(hash) }; + default: + throw new Error(`Unknown base58check version byte: 0x${versionByte.toString(16)}`); + } + } catch (e) { + // Not base58check, try bech32/bech32m below + } + + // Try bech32 (P2WPKH / P2WSH) and bech32m (P2TR) + let decoded: { prefix: string; words: number[] }; + let isBech32m = false; + + try { + decoded = bech32.decode(address); + } catch { + try { + decoded = bech32m.decode(address); + isBech32m = true; + } catch { + throw new Error(`Unable to decode Bitcoin address: ${address}`); + } + } + + const witnessVersion = decoded.words[0]; + const data = Buffer.from(bech32.fromWords(decoded.words.slice(1))); + + if (witnessVersion === 0 && !isBech32m) { + if (data.length === 20) { + return { version: SbtcAddressVersion.P2WPKH, hashBytes: data }; + } else if (data.length === 32) { + return { version: SbtcAddressVersion.P2WSH, hashBytes: data }; + } + throw new Error(`Invalid witness v0 program length: ${data.length}`); + } + + if (witnessVersion === 1 && isBech32m) { + if (data.length === 32) { + return { version: SbtcAddressVersion.P2TR, hashBytes: data }; + } + throw new Error(`Invalid witness v1 program length: ${data.length}`); + } + + throw new Error(`Unsupported witness version ${witnessVersion} for address: ${address}`); +} + +/** + * Check whether a string is a valid Bitcoin address decodable for sBTC withdrawals. + * + * @param {string} address - The address to validate + * @returns {boolean} true if the address can be decoded + */ +export function isValidBtcAddress(address: string): boolean { + try { + decodeBtcAddress(address); + return true; + } catch { + return false; + } +} diff --git a/modules/sdk-coin-stx/src/lib/constants.ts b/modules/sdk-coin-stx/src/lib/constants.ts index 9736ae5b06..bb53cb24fe 100644 --- a/modules/sdk-coin-stx/src/lib/constants.ts +++ b/modules/sdk-coin-stx/src/lib/constants.ts @@ -2,6 +2,8 @@ export const FUNCTION_NAME_SENDMANY = 'send-many'; export const CONTRACT_NAME_SENDMANY = 'send-many-memo'; export const CONTRACT_NAME_STAKING = 'pox-4'; export const FUNCTION_NAME_TRANSFER = 'transfer'; +export const CONTRACT_NAME_SBTC_WITHDRAWAL = 'sbtc-withdrawal'; +export const FUNCTION_NAME_INITIATE_WITHDRAWAL = 'initiate-withdrawal-request'; export const VALID_CONTRACT_FUNCTION_NAMES = [ 'stack-stx', @@ -11,6 +13,7 @@ export const VALID_CONTRACT_FUNCTION_NAMES = [ 'revoke-delegate-stx', 'send-many', 'transfer', + 'initiate-withdrawal-request', ]; export const DEFAULT_SEED_SIZE_BYTES = 64; diff --git a/modules/sdk-coin-stx/src/lib/iface.ts b/modules/sdk-coin-stx/src/lib/iface.ts index a66b42f565..af72e7741b 100644 --- a/modules/sdk-coin-stx/src/lib/iface.ts +++ b/modules/sdk-coin-stx/src/lib/iface.ts @@ -119,3 +119,9 @@ export interface RecoveryInfo extends BaseTransactionExplanation { export interface RecoveryTransaction { txHex: string; } + +export interface SbtcWithdrawParams { + amount: string; + btcAddress: string; + maxFee: string; +} diff --git a/modules/sdk-coin-stx/src/lib/index.ts b/modules/sdk-coin-stx/src/lib/index.ts index f04b317523..612577631e 100644 --- a/modules/sdk-coin-stx/src/lib/index.ts +++ b/modules/sdk-coin-stx/src/lib/index.ts @@ -2,4 +2,5 @@ export { AddressVersion, AddressHashMode } from '@stacks/transactions'; export * from './keyPair'; export * from './transaction'; export * from './transactionBuilderFactory'; +export * from './sbtcWithdrawBuilder'; export * as Utils from './utils'; diff --git a/modules/sdk-coin-stx/src/lib/sbtcWithdrawBuilder.ts b/modules/sdk-coin-stx/src/lib/sbtcWithdrawBuilder.ts new file mode 100644 index 0000000000..c29156afb3 --- /dev/null +++ b/modules/sdk-coin-stx/src/lib/sbtcWithdrawBuilder.ts @@ -0,0 +1,173 @@ +import { BaseCoin as CoinConfig, NetworkType, StacksNetwork as BitgoStacksNetwork } from '@bitgo/statics'; +import BigNum from 'bn.js'; +import { + AddressHashMode, + addressToString, + AddressVersion, + bufferCV, + ClarityType, + createAssetInfo, + FungibleConditionCode, + makeStandardFungiblePostCondition, + PostCondition, + PostConditionMode, + tupleCV, + uintCV, +} from '@stacks/transactions'; +import { BuildTransactionError } from '@bitgo/sdk-core'; +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'; + +const SBTC_TOKEN_CONTRACT_NAME = 'sbtc-token'; +const SBTC_TOKEN_ASSET_NAME = 'sbtc-token'; +const HASHBYTES_BUFFER_LENGTH = 32; + +export class SbtcWithdrawBuilder extends AbstractContractBuilder { + private _withdrawParams: SbtcWithdrawParams | undefined; + private _isDeserialized = false; + + constructor(_coinConfig: Readonly) { + super(_coinConfig); + } + + /** + * Check whether a deserialized contract-call payload matches the sBTC withdrawal contract. + */ + public static isValidContractCall(coinConfig: Readonly, payload: ContractCallPayload): boolean { + return ( + (coinConfig.network as BitgoStacksNetwork).sbtcWithdrawalContractAddress === + addressToString(payload.contractAddress) && + CONTRACT_NAME_SBTC_WITHDRAWAL === payload.contractName.content && + FUNCTION_NAME_INITIATE_WITHDRAWAL === payload.functionName.content + ); + } + + /** + * Set withdrawal parameters. + * + * @param {SbtcWithdrawParams} params - amount (satoshis), btcAddress, maxFee + * @returns {this} + */ + withdraw(params: SbtcWithdrawParams): this { + if (!params.amount || !isValidAmount(params.amount) || params.amount === '0') { + throw new BuildTransactionError('Invalid or missing amount, got: ' + params.amount); + } + if (!params.btcAddress || !isValidBtcAddress(params.btcAddress)) { + throw new BuildTransactionError('Invalid or missing btcAddress, got: ' + params.btcAddress); + } + if (!params.maxFee || !isValidAmount(params.maxFee) || params.maxFee === '0') { + throw new BuildTransactionError('Invalid or missing maxFee, got: ' + params.maxFee); + } + this._withdrawParams = params; + return this; + } + + initBuilder(tx: Transaction): void { + super.initBuilder(tx); + const payload = tx.stxTransaction.payload as ContractCallPayload; + const args = payload.functionArgs; + + if (args.length !== 3) { + throw new BuildTransactionError('Invalid number of function args for sBTC withdrawal'); + } + + // args[0] = uint (amount) + if (args[0].type !== ClarityType.UInt) { + throw new BuildTransactionError('Expected uint for amount argument'); + } + const amount = args[0].value.toString(); + + // args[1] = tuple { version: (buff 1), hashbytes: (buff 32) } + 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']; + if (versionBuf?.type !== ClarityType.Buffer || hashbytesBuf?.type !== ClarityType.Buffer) { + throw new BuildTransactionError('Expected buffer fields in recipient tuple'); + } + + // args[2] = uint (max-fee) + if (args[2].type !== ClarityType.UInt) { + throw new BuildTransactionError('Expected uint for max-fee argument'); + } + const maxFee = args[2].value.toString(); + + this._withdrawParams = { + amount, + btcAddress: '', // not needed for rebuild; function args are preserved from the original tx + maxFee, + }; + this._isDeserialized = true; + } + + /** @inheritdoc */ + protected async buildImplementation(): Promise { + if (!this._withdrawParams) { + throw new BuildTransactionError('Withdrawal params are not set. Use withdraw() to set them.'); + } + + const network = this._coinConfig.network as BitgoStacksNetwork; + this._contractAddress = network.sbtcWithdrawalContractAddress; + this._contractName = CONTRACT_NAME_SBTC_WITHDRAWAL; + this._functionName = FUNCTION_NAME_INITIATE_WITHDRAWAL; + + // For deserialized transactions, function args are already preserved from the original tx. + // For fresh builds, construct them from the withdraw params. + if (!this._isDeserialized) { + this._functionArgs = this.withdrawParamsToFunctionArgs(this._withdrawParams); + } + + this._postConditionMode = PostConditionMode.Deny; + this._postConditions = this.withdrawParamsToPostCondition(this._withdrawParams); + return await super.buildImplementation(); + } + + private withdrawParamsToFunctionArgs(params: SbtcWithdrawParams) { + const decoded = decodeBtcAddress(params.btcAddress); + + // Pad 20-byte hashes to 32 bytes with trailing zeros per sBTC contract spec (buff 32) + let hashBytes = decoded.hashBytes; + if (hashBytes.length < HASHBYTES_BUFFER_LENGTH) { + const padded = Buffer.alloc(HASHBYTES_BUFFER_LENGTH, 0); + hashBytes.copy(padded); + hashBytes = padded; + } + + return [ + uintCV(params.amount), + tupleCV({ + version: bufferCV(Buffer.from([decoded.version])), + hashbytes: bufferCV(hashBytes), + }), + uintCV(params.maxFee), + ]; + } + + private withdrawParamsToPostCondition(params: SbtcWithdrawParams): PostCondition[] { + const amount = new BigNum(params.amount).add(new BigNum(params.maxFee)); + const network = this._coinConfig.network as BitgoStacksNetwork; + const sbtcContractAddress = network.sbtcWithdrawalContractAddress; + + return [ + makeStandardFungiblePostCondition( + getSTXAddressFromPubKeys( + this._fromPubKeys, + this._coinConfig.network.type === NetworkType.MAINNET + ? AddressVersion.MainnetMultiSig + : AddressVersion.TestnetMultiSig, + this._fromPubKeys.length > 1 ? AddressHashMode.SerializeP2SH : AddressHashMode.SerializeP2PKH, + this._numberSignatures + ).address, + FungibleConditionCode.Equal, + amount, + createAssetInfo(sbtcContractAddress, SBTC_TOKEN_CONTRACT_NAME, SBTC_TOKEN_ASSET_NAME) + ), + ]; + } +} diff --git a/modules/sdk-coin-stx/src/lib/transactionBuilderFactory.ts b/modules/sdk-coin-stx/src/lib/transactionBuilderFactory.ts index bf3e75f345..54bc96874f 100644 --- a/modules/sdk-coin-stx/src/lib/transactionBuilderFactory.ts +++ b/modules/sdk-coin-stx/src/lib/transactionBuilderFactory.ts @@ -12,6 +12,7 @@ import { Transaction } from './transaction'; import { ContractBuilder } from './contractBuilder'; import { Utils } from '.'; import { SendmanyBuilder } from './sendmanyBuilder'; +import { SbtcWithdrawBuilder } from './sbtcWithdrawBuilder'; import { FungibleTokenTransferBuilder } from './fungibleTokenTransferBuilder'; export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { @@ -31,6 +32,9 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { if (SendmanyBuilder.isValidContractCall(this._coinConfig, tx.stxTransaction.payload)) { return this.getSendmanyBuilder(tx); } + if (SbtcWithdrawBuilder.isValidContractCall(this._coinConfig, tx.stxTransaction.payload)) { + return this.getSbtcWithdrawBuilder(tx); + } if (FungibleTokenTransferBuilder.isFungibleTokenTransferContractCall(tx.stxTransaction.payload)) { return this.getFungibleTokenTransferBuilder(tx); } @@ -71,6 +75,10 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { return TransactionBuilderFactory.initializeBuilder(new SendmanyBuilder(this._coinConfig), tx); } + getSbtcWithdrawBuilder(tx?: Transaction): SbtcWithdrawBuilder { + return TransactionBuilderFactory.initializeBuilder(new SbtcWithdrawBuilder(this._coinConfig), tx); + } + getFungibleTokenTransferBuilder(tx?: Transaction): FungibleTokenTransferBuilder { return TransactionBuilderFactory.initializeBuilder(new FungibleTokenTransferBuilder(this._coinConfig), tx); } diff --git a/modules/sdk-coin-stx/src/lib/utils.ts b/modules/sdk-coin-stx/src/lib/utils.ts index b037d4cf51..c82fc52498 100644 --- a/modules/sdk-coin-stx/src/lib/utils.ts +++ b/modules/sdk-coin-stx/src/lib/utils.ts @@ -240,7 +240,11 @@ export function isValidMemo(memo: string): boolean { * @returns {boolean} - the validation result */ export function isValidContractAddress(addr: string, network: BitgoStacksNetwork): boolean { - return addr === network.stakingContractAddress || addr === network.sendmanymemoContractAddress; + return ( + addr === network.stakingContractAddress || + addr === network.sendmanymemoContractAddress || + addr === network.sbtcWithdrawalContractAddress + ); } /** diff --git a/modules/sdk-coin-stx/test/unit/btcAddressUtils.ts b/modules/sdk-coin-stx/test/unit/btcAddressUtils.ts new file mode 100644 index 0000000000..e844a031fd --- /dev/null +++ b/modules/sdk-coin-stx/test/unit/btcAddressUtils.ts @@ -0,0 +1,88 @@ +import should from 'should'; +import { decodeBtcAddress, isValidBtcAddress, SbtcAddressVersion } from '../../src/lib/btcAddressUtils'; + +describe('btcAddressUtils', function () { + describe('decodeBtcAddress', function () { + // Mainnet P2PKH + it('should decode a mainnet P2PKH address', function () { + const result = decodeBtcAddress('1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2'); + result.version.should.equal(SbtcAddressVersion.P2PKH); + result.hashBytes.length.should.equal(20); + }); + + // Testnet P2PKH + it('should decode a testnet P2PKH address', function () { + const result = decodeBtcAddress('mipcBbFg9gMiCh81Kj8tqqdgoZub1ZJRfn'); + result.version.should.equal(SbtcAddressVersion.P2PKH); + result.hashBytes.length.should.equal(20); + }); + + // Mainnet P2SH + it('should decode a mainnet P2SH address', function () { + const result = decodeBtcAddress('3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy'); + result.version.should.equal(SbtcAddressVersion.P2SH); + result.hashBytes.length.should.equal(20); + }); + + // Testnet P2SH + it('should decode a testnet P2SH address', function () { + const result = decodeBtcAddress('2MzQwSSnBHWHqSAqtTVQ6v47XtaisrJa1Vc'); + result.version.should.equal(SbtcAddressVersion.P2SH); + result.hashBytes.length.should.equal(20); + }); + + // Mainnet P2WPKH (bech32, witness v0, 20-byte program) + it('should decode a mainnet P2WPKH address', function () { + const result = decodeBtcAddress('bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4'); + result.version.should.equal(SbtcAddressVersion.P2WPKH); + result.hashBytes.length.should.equal(20); + }); + + // Testnet P2WPKH + it('should decode a testnet P2WPKH address', function () { + const result = decodeBtcAddress('tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx'); + result.version.should.equal(SbtcAddressVersion.P2WPKH); + result.hashBytes.length.should.equal(20); + }); + + // Mainnet P2WSH (bech32, witness v0, 32-byte program) + it('should decode a mainnet P2WSH address', function () { + const result = decodeBtcAddress('bc1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3qccfmv3'); + result.version.should.equal(SbtcAddressVersion.P2WSH); + result.hashBytes.length.should.equal(32); + }); + + // Mainnet P2TR (bech32m, witness v1, 32-byte program) + it('should decode a mainnet P2TR address', function () { + const result = decodeBtcAddress('bc1p0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqzk5jj0'); + result.version.should.equal(SbtcAddressVersion.P2TR); + result.hashBytes.length.should.equal(32); + }); + + // Testnet P2TR + it('should decode a testnet P2TR address', function () { + const result = decodeBtcAddress('tb1pqqqqp399et2xygdj5xreqhjjvcmzhxw4aywxecjdzew6hylgvsesf3hn0c'); + result.version.should.equal(SbtcAddressVersion.P2TR); + result.hashBytes.length.should.equal(32); + }); + + it('should throw on an invalid address', function () { + should.throws(() => decodeBtcAddress('invalidaddress')); + }); + }); + + describe('isValidBtcAddress', function () { + it('should return true for valid addresses', function () { + isValidBtcAddress('1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2').should.be.true(); + isValidBtcAddress('3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy').should.be.true(); + isValidBtcAddress('bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4').should.be.true(); + isValidBtcAddress('bc1p0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqzk5jj0').should.be.true(); + }); + + it('should return false for invalid addresses', function () { + isValidBtcAddress('').should.be.false(); + isValidBtcAddress('notanaddress').should.be.false(); + isValidBtcAddress('SP10FDHQQ4F2F0KHMN6Z24RMAMGX5933SQJCWKAAR').should.be.false(); // STX address + }); + }); +}); diff --git a/modules/sdk-coin-stx/test/unit/transactionBuilder/sbtcWithdrawBuilder.ts b/modules/sdk-coin-stx/test/unit/transactionBuilder/sbtcWithdrawBuilder.ts new file mode 100644 index 0000000000..4510a23e5b --- /dev/null +++ b/modules/sdk-coin-stx/test/unit/transactionBuilder/sbtcWithdrawBuilder.ts @@ -0,0 +1,273 @@ +import should from 'should'; +import { pubKeyfromPrivKey, publicKeyToString } from '@stacks/transactions'; + +import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test'; +import { BitGoAPI } from '@bitgo/sdk-api'; +import { coins } from '@bitgo/statics'; + +import { Stx, Tstx, StxLib } from '../../../src'; +import * as testData from '../resources'; + +describe('Stacks: sBTC Withdraw Builder', function () { + const coinNameTest = 'tstx'; + let bitgo: TestBitGoAPI; + + before(function () { + bitgo = TestBitGo.decorate(BitGoAPI, { + env: 'mock', + }); + bitgo.initializeTestVars(); + bitgo.safeRegister('stx', Stx.createInstance); + bitgo.safeRegister('tstx', Tstx.createInstance); + }); + + describe('sBTC Withdraw Builder', () => { + const factory = new StxLib.TransactionBuilderFactory(coins.get(coinNameTest)); + + const initTxBuilder = () => { + const txBuilder = factory.getSbtcWithdrawBuilder(); + txBuilder.fee({ fee: '1000' }); + txBuilder.nonce(1); + return txBuilder; + }; + + 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); + builder.fromPubKey(pubKeyStrings); + builder.numberSignatures(2); + builder.withdraw({ + amount: '100000', + btcAddress: '1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2', + maxFee: '5000', + }); + builder.sign({ key: testData.prvKeysString[0] }); + builder.sign({ key: testData.prvKeysString[1] }); + const tx = await builder.build(); + const txJson = tx.toJson(); + txJson.fee.should.equal('1000'); + txJson.nonce.should.equal(1); + should.exist(txJson.payload); + txJson.payload.should.have.property('contractName', 'sbtc-withdrawal'); + txJson.payload.should.have.property('functionName', 'initiate-withdrawal-request'); + }); + + it('a withdrawal with P2SH address', async () => { + const builder = initTxBuilder(); + const pubKeys = testData.prvKeysString.map(pubKeyfromPrivKey); + const pubKeyStrings = pubKeys.map(publicKeyToString); + builder.fromPubKey(pubKeyStrings); + builder.numberSignatures(2); + builder.withdraw({ + amount: '200000', + btcAddress: '3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy', + maxFee: '3000', + }); + const tx = await builder.build(); + const txJson = tx.toJson(); + txJson.payload.should.have.property('functionName', 'initiate-withdrawal-request'); + }); + + it('a withdrawal with P2WPKH (bech32) address', async () => { + const builder = initTxBuilder(); + const pubKeys = testData.prvKeysString.map(pubKeyfromPrivKey); + const pubKeyStrings = pubKeys.map(publicKeyToString); + builder.fromPubKey(pubKeyStrings); + builder.numberSignatures(2); + builder.withdraw({ + amount: '50000', + btcAddress: 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4', + maxFee: '2000', + }); + const tx = await builder.build(); + const txJson = tx.toJson(); + txJson.payload.should.have.property('functionName', 'initiate-withdrawal-request'); + }); + + it('a withdrawal with P2WSH (bech32) address', async () => { + const builder = initTxBuilder(); + const pubKeys = testData.prvKeysString.map(pubKeyfromPrivKey); + const pubKeyStrings = pubKeys.map(publicKeyToString); + builder.fromPubKey(pubKeyStrings); + builder.numberSignatures(2); + builder.withdraw({ + amount: '75000', + btcAddress: 'bc1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3qccfmv3', + maxFee: '4000', + }); + const tx = await builder.build(); + const txJson = tx.toJson(); + txJson.payload.should.have.property('functionName', 'initiate-withdrawal-request'); + }); + + it('a withdrawal with P2TR (bech32m) address', async () => { + const builder = initTxBuilder(); + const pubKeys = testData.prvKeysString.map(pubKeyfromPrivKey); + const pubKeyStrings = pubKeys.map(publicKeyToString); + builder.fromPubKey(pubKeyStrings); + builder.numberSignatures(2); + builder.withdraw({ + amount: '300000', + btcAddress: 'bc1p0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqzk5jj0', + maxFee: '10000', + }); + const tx = await builder.build(); + const txJson = tx.toJson(); + txJson.payload.should.have.property('functionName', 'initiate-withdrawal-request'); + }); + }); + + 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); + builder.fromPubKey(pubKeyStrings); + builder.numberSignatures(2); + builder.withdraw({ + amount: '100000', + btcAddress: '1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2', + maxFee: '5000', + }); + builder.sign({ key: testData.prvKeysString[0] }); + builder.sign({ key: testData.prvKeysString[1] }); + const tx = await builder.build(); + + const rawHex = tx.toBroadcastFormat(); + should.exist(rawHex); + + // Deserialize + const rebuilder = factory.from(rawHex); + const tx2 = await rebuilder.build(); + tx2.toBroadcastFormat().should.equal(rawHex); + }); + + 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); + builder.fromPubKey(pubKeyStrings); + builder.numberSignatures(2); + builder.withdraw({ + amount: '100000', + btcAddress: '1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2', + maxFee: '5000', + }); + const tx = await builder.build(); + + const rawHex = tx.toBroadcastFormat(); + + // Deserialize and sign + const rebuilder = factory.from(rawHex); + rebuilder.sign({ key: testData.prvKeysString[0] }); + rebuilder.sign({ key: testData.prvKeysString[1] }); + const tx2 = await rebuilder.build(); + should.exist(tx2.toBroadcastFormat()); + }); + }); + + describe('validation', function () { + it('should reject invalid amount', function () { + const builder = initTxBuilder(); + should.throws( + () => + builder.withdraw({ + amount: '-1', + btcAddress: '1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2', + maxFee: '5000', + }), + /Invalid or missing amount/ + ); + }); + + it('should reject zero amount', function () { + const builder = initTxBuilder(); + should.throws( + () => + builder.withdraw({ + amount: '0', + btcAddress: '1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2', + maxFee: '5000', + }), + /Invalid or missing amount/ + ); + }); + + it('should reject empty amount', function () { + const builder = initTxBuilder(); + should.throws( + () => + builder.withdraw({ + amount: '', + btcAddress: '1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2', + maxFee: '5000', + }), + /Invalid or missing amount/ + ); + }); + + it('should reject invalid BTC address', function () { + const builder = initTxBuilder(); + should.throws( + () => + builder.withdraw({ + amount: '100000', + btcAddress: 'invalidaddress', + maxFee: '5000', + }), + /Invalid or missing btcAddress/ + ); + }); + + it('should reject invalid maxFee', function () { + const builder = initTxBuilder(); + should.throws( + () => + builder.withdraw({ + amount: '100000', + btcAddress: '1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2', + maxFee: '-100', + }), + /Invalid or missing maxFee/ + ); + }); + + it('should reject zero maxFee', function () { + const builder = initTxBuilder(); + should.throws( + () => + builder.withdraw({ + amount: '100000', + btcAddress: '1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2', + maxFee: '0', + }), + /Invalid or missing maxFee/ + ); + }); + + it('should reject empty maxFee', function () { + const builder = initTxBuilder(); + should.throws( + () => + builder.withdraw({ + amount: '100000', + btcAddress: '1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2', + maxFee: '', + }), + /Invalid or missing maxFee/ + ); + }); + + it('should fail to build without withdraw params', async () => { + const builder = initTxBuilder(); + const pubKeys = testData.prvKeysString.map(pubKeyfromPrivKey); + const pubKeyStrings = pubKeys.map(publicKeyToString); + builder.fromPubKey(pubKeyStrings); + builder.numberSignatures(2); + await builder.build().should.be.rejectedWith(/Withdrawal params are not set/); + }); + }); + }); +}); diff --git a/modules/sdk-core/src/bitgo/wallet/BuildParams.ts b/modules/sdk-core/src/bitgo/wallet/BuildParams.ts index c7c4ffa7c5..0d6ea7964d 100644 --- a/modules/sdk-core/src/bitgo/wallet/BuildParams.ts +++ b/modules/sdk-core/src/bitgo/wallet/BuildParams.ts @@ -46,6 +46,17 @@ export const BuildParamsStacks = t.partial({ functionArgs: t.unknown, }); +export const SbtcWithdrawParams = t.partial({ + amount: t.string, + btcAddress: t.string, + maxFee: t.string, +}); + +export const BuildParamsSbtc = t.partial({ + sbtcWithdrawParams: SbtcWithdrawParams, + sbtcDepositParams: t.unknown, +}); + export const BuildParamsOffchain = t.partial({ idfSignedTimestamp: t.unknown, idfVersion: t.unknown, @@ -56,6 +67,7 @@ export const BuildParams = t.exact( t.intersection([ BuildParamsUTXO, BuildParamsStacks, + BuildParamsSbtc, BuildParamsOffchain, t.partial({ apiVersion: t.unknown, diff --git a/modules/statics/src/networks.ts b/modules/statics/src/networks.ts index f0461a9ea5..58a3e87768 100644 --- a/modules/statics/src/networks.ts +++ b/modules/statics/src/networks.ts @@ -187,6 +187,7 @@ export interface TronNetwork extends AccountNetwork { export interface StacksNetwork extends AccountNetwork { readonly sendmanymemoContractAddress: string; readonly stakingContractAddress: string; + readonly sbtcWithdrawalContractAddress: string; } // eslint-disable-next-line @typescript-eslint/no-empty-interface @@ -1237,6 +1238,7 @@ class Stx extends Mainnet implements StacksNetwork { explorerUrl = 'https://explorer.hiro.so/txid/'; sendmanymemoContractAddress = 'SP3FBR2AGK5H9QBDH3EEN6DF8EK8JY7RX8QJ5SVTE'; stakingContractAddress = 'SP000000000000000000002Q6VF78'; + sbtcWithdrawalContractAddress = 'SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4'; } class StxTestnet extends Testnet implements StacksNetwork { @@ -1245,6 +1247,7 @@ class StxTestnet extends Testnet implements StacksNetwork { explorerUrl = 'https://explorer.hiro.so/txid/?chain=testnet'; sendmanymemoContractAddress = 'ST3F1X4QGV2SM8XD96X45M6RTQXKA1PZJZZCQAB4B'; stakingContractAddress = 'ST000000000000000000002AMW42H'; + sbtcWithdrawalContractAddress = 'SN69P7RZRKK8ERQCCABHT2JWKB2S4DHH9H74231T'; } class SUSD extends Mainnet implements AccountNetwork {