Skip to content

Commit 02193be

Browse files
committed
feat(sdk-coin-stx): add sBTC withdrawal transaction builder
Add SbtcWithdrawBuilder for constructing initiate-withdrawal-request contract calls to the sBTC withdrawal contract. Includes BTC address decoding utilities supporting P2PKH, P2SH, P2WPKH, P2WSH, and P2TR address types, with post-condition enforcement for fungible token transfers. Ticket: CSHLD-597
1 parent 85d204c commit 02193be

12 files changed

Lines changed: 680 additions & 1 deletion

File tree

modules/sdk-coin-stx/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,10 @@
4646
"@noble/curves": "1.8.1",
4747
"@stacks/network": "^4.3.0",
4848
"@stacks/transactions": "2.0.1",
49+
"bech32": "^2.0.0",
4950
"bignumber.js": "^9.0.0",
5051
"bn.js": "^5.2.1",
52+
"bs58check": "^2.1.2",
5153
"ethereumjs-util": "7.1.5",
5254
"lodash": "^4.18.0"
5355
},
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import * as bs58check from 'bs58check';
2+
import { bech32, bech32m } from 'bech32';
3+
4+
/**
5+
* sBTC address version bytes as defined by the sBTC withdrawal contract.
6+
*/
7+
export enum SbtcAddressVersion {
8+
P2PKH = 0x00,
9+
P2SH = 0x01,
10+
P2WPKH = 0x04,
11+
P2WSH = 0x05,
12+
P2TR = 0x06,
13+
}
14+
15+
interface DecodedBtcAddress {
16+
version: SbtcAddressVersion;
17+
hashBytes: Buffer;
18+
}
19+
20+
const BASE58_MAINNET_P2PKH = 0x00;
21+
const BASE58_MAINNET_P2SH = 0x05;
22+
const BASE58_TESTNET_P2PKH = 0x6f;
23+
const BASE58_TESTNET_P2SH = 0xc4;
24+
25+
/**
26+
* Decode a Bitcoin address into an sBTC version byte and hash bytes.
27+
*
28+
* @param {string} address - A Bitcoin address (P2PKH, P2SH, P2WPKH, P2WSH, or P2TR)
29+
* @returns {DecodedBtcAddress} The sBTC version and raw hash bytes
30+
*/
31+
export function decodeBtcAddress(address: string): DecodedBtcAddress {
32+
// Try base58check first (P2PKH / P2SH)
33+
try {
34+
const decoded = bs58check.decode(address);
35+
const versionByte = decoded[0];
36+
const hash = decoded.slice(1);
37+
38+
if (hash.length !== 20) {
39+
throw new Error(`Invalid base58check hash length: ${hash.length}`);
40+
}
41+
42+
switch (versionByte) {
43+
case BASE58_MAINNET_P2PKH:
44+
case BASE58_TESTNET_P2PKH:
45+
return { version: SbtcAddressVersion.P2PKH, hashBytes: Buffer.from(hash) };
46+
case BASE58_MAINNET_P2SH:
47+
case BASE58_TESTNET_P2SH:
48+
return { version: SbtcAddressVersion.P2SH, hashBytes: Buffer.from(hash) };
49+
default:
50+
throw new Error(`Unknown base58check version byte: 0x${versionByte.toString(16)}`);
51+
}
52+
} catch (e) {
53+
// Not base58check, try bech32/bech32m below
54+
}
55+
56+
// Try bech32 (P2WPKH / P2WSH) and bech32m (P2TR)
57+
let decoded: { prefix: string; words: number[] };
58+
let isBech32m = false;
59+
60+
try {
61+
decoded = bech32.decode(address);
62+
} catch {
63+
try {
64+
decoded = bech32m.decode(address);
65+
isBech32m = true;
66+
} catch {
67+
throw new Error(`Unable to decode Bitcoin address: ${address}`);
68+
}
69+
}
70+
71+
const witnessVersion = decoded.words[0];
72+
const data = Buffer.from(bech32.fromWords(decoded.words.slice(1)));
73+
74+
if (witnessVersion === 0 && !isBech32m) {
75+
if (data.length === 20) {
76+
return { version: SbtcAddressVersion.P2WPKH, hashBytes: data };
77+
} else if (data.length === 32) {
78+
return { version: SbtcAddressVersion.P2WSH, hashBytes: data };
79+
}
80+
throw new Error(`Invalid witness v0 program length: ${data.length}`);
81+
}
82+
83+
if (witnessVersion === 1 && isBech32m) {
84+
if (data.length === 32) {
85+
return { version: SbtcAddressVersion.P2TR, hashBytes: data };
86+
}
87+
throw new Error(`Invalid witness v1 program length: ${data.length}`);
88+
}
89+
90+
throw new Error(`Unsupported witness version ${witnessVersion} for address: ${address}`);
91+
}
92+
93+
/**
94+
* Check whether a string is a valid Bitcoin address decodable for sBTC withdrawals.
95+
*
96+
* @param {string} address - The address to validate
97+
* @returns {boolean} true if the address can be decoded
98+
*/
99+
export function isValidBtcAddress(address: string): boolean {
100+
try {
101+
decodeBtcAddress(address);
102+
return true;
103+
} catch {
104+
return false;
105+
}
106+
}

modules/sdk-coin-stx/src/lib/constants.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ export const FUNCTION_NAME_SENDMANY = 'send-many';
22
export const CONTRACT_NAME_SENDMANY = 'send-many-memo';
33
export const CONTRACT_NAME_STAKING = 'pox-4';
44
export const FUNCTION_NAME_TRANSFER = 'transfer';
5+
export const CONTRACT_NAME_SBTC_WITHDRAWAL = 'sbtc-withdrawal';
6+
export const FUNCTION_NAME_INITIATE_WITHDRAWAL = 'initiate-withdrawal-request';
57

68
export const VALID_CONTRACT_FUNCTION_NAMES = [
79
'stack-stx',
@@ -11,6 +13,7 @@ export const VALID_CONTRACT_FUNCTION_NAMES = [
1113
'revoke-delegate-stx',
1214
'send-many',
1315
'transfer',
16+
'initiate-withdrawal-request',
1417
];
1518

1619
export const DEFAULT_SEED_SIZE_BYTES = 64;

modules/sdk-coin-stx/src/lib/iface.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,3 +119,9 @@ export interface RecoveryInfo extends BaseTransactionExplanation {
119119
export interface RecoveryTransaction {
120120
txHex: string;
121121
}
122+
123+
export interface SbtcWithdrawParams {
124+
amount: string;
125+
btcAddress: string;
126+
maxFee: string;
127+
}

modules/sdk-coin-stx/src/lib/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,5 @@ export { AddressVersion, AddressHashMode } from '@stacks/transactions';
22
export * from './keyPair';
33
export * from './transaction';
44
export * from './transactionBuilderFactory';
5+
export * from './sbtcWithdrawBuilder';
56
export * as Utils from './utils';
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import { BaseCoin as CoinConfig, NetworkType, StacksNetwork as BitgoStacksNetwork } from '@bitgo/statics';
2+
import BigNum from 'bn.js';
3+
import {
4+
AddressHashMode,
5+
addressToString,
6+
AddressVersion,
7+
bufferCV,
8+
ClarityType,
9+
createAssetInfo,
10+
FungibleConditionCode,
11+
makeStandardFungiblePostCondition,
12+
PostCondition,
13+
PostConditionMode,
14+
tupleCV,
15+
uintCV,
16+
} from '@stacks/transactions';
17+
import { BuildTransactionError } from '@bitgo/sdk-core';
18+
import { Transaction } from './transaction';
19+
import { getSTXAddressFromPubKeys, isValidAmount } from './utils';
20+
import { SbtcWithdrawParams } from './iface';
21+
import { CONTRACT_NAME_SBTC_WITHDRAWAL, FUNCTION_NAME_INITIATE_WITHDRAWAL } from './constants';
22+
import { ContractCallPayload } from '@stacks/transactions/dist/payload';
23+
import { AbstractContractBuilder } from './abstractContractBuilder';
24+
import { decodeBtcAddress, isValidBtcAddress } from './btcAddressUtils';
25+
26+
const SBTC_TOKEN_CONTRACT_NAME = 'sbtc-token';
27+
const SBTC_TOKEN_ASSET_NAME = 'sbtc-token';
28+
const HASHBYTES_BUFFER_LENGTH = 32;
29+
30+
export class SbtcWithdrawBuilder extends AbstractContractBuilder {
31+
private _withdrawParams: SbtcWithdrawParams | undefined;
32+
private _isDeserialized = false;
33+
34+
constructor(_coinConfig: Readonly<CoinConfig>) {
35+
super(_coinConfig);
36+
}
37+
38+
/**
39+
* Check whether a deserialized contract-call payload matches the sBTC withdrawal contract.
40+
*/
41+
public static isValidContractCall(coinConfig: Readonly<CoinConfig>, payload: ContractCallPayload): boolean {
42+
return (
43+
(coinConfig.network as BitgoStacksNetwork).sbtcWithdrawalContractAddress ===
44+
addressToString(payload.contractAddress) &&
45+
CONTRACT_NAME_SBTC_WITHDRAWAL === payload.contractName.content &&
46+
FUNCTION_NAME_INITIATE_WITHDRAWAL === payload.functionName.content
47+
);
48+
}
49+
50+
/**
51+
* Set withdrawal parameters.
52+
*
53+
* @param {SbtcWithdrawParams} params - amount (satoshis), btcAddress, maxFee
54+
* @returns {this}
55+
*/
56+
withdraw(params: SbtcWithdrawParams): this {
57+
if (!params.amount || !isValidAmount(params.amount) || params.amount === '0') {
58+
throw new BuildTransactionError('Invalid or missing amount, got: ' + params.amount);
59+
}
60+
if (!params.btcAddress || !isValidBtcAddress(params.btcAddress)) {
61+
throw new BuildTransactionError('Invalid or missing btcAddress, got: ' + params.btcAddress);
62+
}
63+
if (!params.maxFee || !isValidAmount(params.maxFee) || params.maxFee === '0') {
64+
throw new BuildTransactionError('Invalid or missing maxFee, got: ' + params.maxFee);
65+
}
66+
this._withdrawParams = params;
67+
return this;
68+
}
69+
70+
initBuilder(tx: Transaction): void {
71+
super.initBuilder(tx);
72+
const payload = tx.stxTransaction.payload as ContractCallPayload;
73+
const args = payload.functionArgs;
74+
75+
if (args.length !== 3) {
76+
throw new BuildTransactionError('Invalid number of function args for sBTC withdrawal');
77+
}
78+
79+
// args[0] = uint (amount)
80+
if (args[0].type !== ClarityType.UInt) {
81+
throw new BuildTransactionError('Expected uint for amount argument');
82+
}
83+
const amount = args[0].value.toString();
84+
85+
// args[1] = tuple { version: (buff 1), hashbytes: (buff 32) }
86+
if (args[1].type !== ClarityType.Tuple) {
87+
throw new BuildTransactionError('Expected tuple for recipient argument');
88+
}
89+
const versionBuf = args[1].data['version'];
90+
const hashbytesBuf = args[1].data['hashbytes'];
91+
if (versionBuf?.type !== ClarityType.Buffer || hashbytesBuf?.type !== ClarityType.Buffer) {
92+
throw new BuildTransactionError('Expected buffer fields in recipient tuple');
93+
}
94+
95+
// args[2] = uint (max-fee)
96+
if (args[2].type !== ClarityType.UInt) {
97+
throw new BuildTransactionError('Expected uint for max-fee argument');
98+
}
99+
const maxFee = args[2].value.toString();
100+
101+
this._withdrawParams = {
102+
amount,
103+
btcAddress: '', // not needed for rebuild; function args are preserved from the original tx
104+
maxFee,
105+
};
106+
this._isDeserialized = true;
107+
}
108+
109+
/** @inheritdoc */
110+
protected async buildImplementation(): Promise<Transaction> {
111+
if (!this._withdrawParams) {
112+
throw new BuildTransactionError('Withdrawal params are not set. Use withdraw() to set them.');
113+
}
114+
115+
const network = this._coinConfig.network as BitgoStacksNetwork;
116+
this._contractAddress = network.sbtcWithdrawalContractAddress;
117+
this._contractName = CONTRACT_NAME_SBTC_WITHDRAWAL;
118+
this._functionName = FUNCTION_NAME_INITIATE_WITHDRAWAL;
119+
120+
// For deserialized transactions, function args are already preserved from the original tx.
121+
// For fresh builds, construct them from the withdraw params.
122+
if (!this._isDeserialized) {
123+
this._functionArgs = this.withdrawParamsToFunctionArgs(this._withdrawParams);
124+
}
125+
126+
this._postConditionMode = PostConditionMode.Deny;
127+
this._postConditions = this.withdrawParamsToPostCondition(this._withdrawParams);
128+
return await super.buildImplementation();
129+
}
130+
131+
private withdrawParamsToFunctionArgs(params: SbtcWithdrawParams) {
132+
const decoded = decodeBtcAddress(params.btcAddress);
133+
134+
// Pad 20-byte hashes to 32 bytes with trailing zeros per sBTC contract spec (buff 32)
135+
let hashBytes = decoded.hashBytes;
136+
if (hashBytes.length < HASHBYTES_BUFFER_LENGTH) {
137+
const padded = Buffer.alloc(HASHBYTES_BUFFER_LENGTH, 0);
138+
hashBytes.copy(padded);
139+
hashBytes = padded;
140+
}
141+
142+
return [
143+
uintCV(params.amount),
144+
tupleCV({
145+
version: bufferCV(Buffer.from([decoded.version])),
146+
hashbytes: bufferCV(hashBytes),
147+
}),
148+
uintCV(params.maxFee),
149+
];
150+
}
151+
152+
private withdrawParamsToPostCondition(params: SbtcWithdrawParams): PostCondition[] {
153+
const amount = new BigNum(params.amount).add(new BigNum(params.maxFee));
154+
const network = this._coinConfig.network as BitgoStacksNetwork;
155+
const sbtcContractAddress = network.sbtcWithdrawalContractAddress;
156+
157+
return [
158+
makeStandardFungiblePostCondition(
159+
getSTXAddressFromPubKeys(
160+
this._fromPubKeys,
161+
this._coinConfig.network.type === NetworkType.MAINNET
162+
? AddressVersion.MainnetMultiSig
163+
: AddressVersion.TestnetMultiSig,
164+
this._fromPubKeys.length > 1 ? AddressHashMode.SerializeP2SH : AddressHashMode.SerializeP2PKH,
165+
this._numberSignatures
166+
).address,
167+
FungibleConditionCode.Equal,
168+
amount,
169+
createAssetInfo(sbtcContractAddress, SBTC_TOKEN_CONTRACT_NAME, SBTC_TOKEN_ASSET_NAME)
170+
),
171+
];
172+
}
173+
}

modules/sdk-coin-stx/src/lib/transactionBuilderFactory.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { Transaction } from './transaction';
1212
import { ContractBuilder } from './contractBuilder';
1313
import { Utils } from '.';
1414
import { SendmanyBuilder } from './sendmanyBuilder';
15+
import { SbtcWithdrawBuilder } from './sbtcWithdrawBuilder';
1516
import { FungibleTokenTransferBuilder } from './fungibleTokenTransferBuilder';
1617

1718
export class TransactionBuilderFactory extends BaseTransactionBuilderFactory {
@@ -31,6 +32,9 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory {
3132
if (SendmanyBuilder.isValidContractCall(this._coinConfig, tx.stxTransaction.payload)) {
3233
return this.getSendmanyBuilder(tx);
3334
}
35+
if (SbtcWithdrawBuilder.isValidContractCall(this._coinConfig, tx.stxTransaction.payload)) {
36+
return this.getSbtcWithdrawBuilder(tx);
37+
}
3438
if (FungibleTokenTransferBuilder.isFungibleTokenTransferContractCall(tx.stxTransaction.payload)) {
3539
return this.getFungibleTokenTransferBuilder(tx);
3640
}
@@ -71,6 +75,10 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory {
7175
return TransactionBuilderFactory.initializeBuilder(new SendmanyBuilder(this._coinConfig), tx);
7276
}
7377

78+
getSbtcWithdrawBuilder(tx?: Transaction): SbtcWithdrawBuilder {
79+
return TransactionBuilderFactory.initializeBuilder(new SbtcWithdrawBuilder(this._coinConfig), tx);
80+
}
81+
7482
getFungibleTokenTransferBuilder(tx?: Transaction): FungibleTokenTransferBuilder {
7583
return TransactionBuilderFactory.initializeBuilder(new FungibleTokenTransferBuilder(this._coinConfig), tx);
7684
}

modules/sdk-coin-stx/src/lib/utils.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,11 @@ export function isValidMemo(memo: string): boolean {
240240
* @returns {boolean} - the validation result
241241
*/
242242
export function isValidContractAddress(addr: string, network: BitgoStacksNetwork): boolean {
243-
return addr === network.stakingContractAddress || addr === network.sendmanymemoContractAddress;
243+
return (
244+
addr === network.stakingContractAddress ||
245+
addr === network.sendmanymemoContractAddress ||
246+
addr === network.sbtcWithdrawalContractAddress
247+
);
244248
}
245249

246250
/**

0 commit comments

Comments
 (0)