From f3ca192973615e5b9ec33881fd9b5d28ab9351de Mon Sep 17 00:00:00 2001 From: Doddanna17 Date: Thu, 3 Sep 2026 17:14:48 +0530 Subject: [PATCH] feat(sdk-coin-stx): add PoX-5 bond builders Add typed STX transaction construction for PoX-5 bonds, reward paths, and lockup proofs. Ticket: SI-1486 --- modules/sdk-coin-stx/src/lib/constants.ts | 28 ++ .../sdk-coin-stx/src/lib/contractBuilder.ts | 46 +++- modules/sdk-coin-stx/src/lib/index.ts | 2 + modules/sdk-coin-stx/src/lib/pox5Builder.ts | 253 ++++++++++++++++++ .../src/lib/transactionBuilderFactory.ts | 8 + modules/sdk-coin-stx/src/lib/utils.ts | 16 +- .../transactionBuilder/contractBuilder.ts | 42 ++- .../unit/transactionBuilder/pox5Builder.ts | 146 ++++++++++ .../src/bitgo/staking/iStakingWallet.ts | 24 +- .../src/bitgo/staking/stakingWallet.ts | 11 +- 10 files changed, 563 insertions(+), 13 deletions(-) create mode 100644 modules/sdk-coin-stx/src/lib/pox5Builder.ts create mode 100644 modules/sdk-coin-stx/test/unit/transactionBuilder/pox5Builder.ts diff --git a/modules/sdk-coin-stx/src/lib/constants.ts b/modules/sdk-coin-stx/src/lib/constants.ts index bb53cb24fe..e82b367572 100644 --- a/modules/sdk-coin-stx/src/lib/constants.ts +++ b/modules/sdk-coin-stx/src/lib/constants.ts @@ -1,10 +1,37 @@ export const FUNCTION_NAME_SENDMANY = 'send-many'; export const CONTRACT_NAME_SENDMANY = 'send-many-memo'; export const CONTRACT_NAME_STAKING = 'pox-4'; +export const CONTRACT_NAME_POX5 = 'pox-5'; +export const CONTRACT_NAME_STAKING_POX5 = CONTRACT_NAME_POX5; +export const VALID_STAKING_CONTRACT_NAMES = [CONTRACT_NAME_STAKING, CONTRACT_NAME_POX5]; +export const POX5_CONTRACT_ADDRESS_TESTNET = 'ST000000000000000000002AMW42H'; +export const POX5_CONTRACT_ADDRESS_MAINNET = 'SP000000000000000000002Q6VF78'; 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 FUNCTION_NAME_STAKE = 'stake'; +export const FUNCTION_NAME_STAKE_UPDATE = 'stake-update'; +export const FUNCTION_NAME_UNSTAKE = 'unstake'; +export const FUNCTION_NAME_REGISTER_FOR_BOND = 'register-for-bond'; +export const FUNCTION_NAME_ANNOUNCE_L1_EARLY_EXIT = 'announce-l1-early-exit'; +export const FUNCTION_NAME_UPDATE_BOND_REGISTRATION = 'update-bond-registration'; +export const FUNCTION_NAME_CLAIM_REWARDS = 'claim-rewards'; +export const FUNCTION_NAME_CLAIM_STAKER_REWARDS = 'claim-staker-rewards-for-signer'; +export const FUNCTION_NAME_CALCULATE_REWARDS = 'calculate-rewards'; + +export const VALID_POX5_CONTRACT_FUNCTION_NAMES = [ + FUNCTION_NAME_STAKE, + FUNCTION_NAME_STAKE_UPDATE, + FUNCTION_NAME_UNSTAKE, + FUNCTION_NAME_REGISTER_FOR_BOND, + FUNCTION_NAME_UPDATE_BOND_REGISTRATION, + FUNCTION_NAME_ANNOUNCE_L1_EARLY_EXIT, + FUNCTION_NAME_CLAIM_REWARDS, + FUNCTION_NAME_CLAIM_STAKER_REWARDS, + FUNCTION_NAME_CALCULATE_REWARDS, +]; + export const VALID_CONTRACT_FUNCTION_NAMES = [ 'stack-stx', 'delegate-stx', @@ -14,6 +41,7 @@ export const VALID_CONTRACT_FUNCTION_NAMES = [ 'send-many', 'transfer', 'initiate-withdrawal-request', + ...VALID_POX5_CONTRACT_FUNCTION_NAMES, ]; export const DEFAULT_SEED_SIZE_BYTES = 64; diff --git a/modules/sdk-coin-stx/src/lib/contractBuilder.ts b/modules/sdk-coin-stx/src/lib/contractBuilder.ts index 71b4855f2b..f93f9da55a 100644 --- a/modules/sdk-coin-stx/src/lib/contractBuilder.ts +++ b/modules/sdk-coin-stx/src/lib/contractBuilder.ts @@ -7,7 +7,12 @@ import { ClarityValue, encodeClarityValue, noneCV, + listCV, + responseErrorCV, + responseOkCV, someCV, + contractPrincipalCV, + standardPrincipalCV, tupleCV, } from '@stacks/transactions'; import { InvalidParameterValueError } from '@bitgo/sdk-core'; @@ -15,7 +20,7 @@ import { Transaction } from './transaction'; import { isValidAddress } from './utils'; import { ClarityValueJson } from './iface'; import { Utils } from '.'; -import { CONTRACT_NAME_SENDMANY, CONTRACT_NAME_STAKING } from './constants'; +import { CONTRACT_NAME_SENDMANY, VALID_STAKING_CONTRACT_NAMES } from './constants'; import { AbstractContractBuilder } from './abstractContractBuilder'; export class ContractBuilder extends AbstractContractBuilder { @@ -60,8 +65,8 @@ export class ContractBuilder extends AbstractContractBuilder { if (name.length === 0) { throw new InvalidParameterValueError('Invalid name'); } - if (name !== CONTRACT_NAME_STAKING && name !== CONTRACT_NAME_SENDMANY) { - throw new InvalidParameterValueError('Only pox-4 and send-many-memo contracts supported'); + if (!VALID_STAKING_CONTRACT_NAMES.includes(name) && name !== CONTRACT_NAME_SENDMANY) { + throw new InvalidParameterValueError('Only pox-4, pox-5, and send-many-memo contracts supported'); } this._contractName = name; return this; @@ -77,7 +82,7 @@ export class ContractBuilder extends AbstractContractBuilder { if (name.length === 0) { throw new InvalidParameterValueError('Invalid name'); } - if (!Utils.isValidContractFunctionName(name)) { + if (!Utils.isValidContractFunctionName(name, this._contractName)) { throw new InvalidParameterValueError(`${name} is not supported contract function name`); } this._functionName = name; @@ -104,6 +109,22 @@ export class ContractBuilder extends AbstractContractBuilder { } else { return someCV(this.parseCv(arg.val)); } + case 'list': + if (arg.val instanceof Array) { + return listCV(arg.val.map((value) => this.parseCv(value))); + } + throw new InvalidParameterValueError('list requires Array val'); + case 'response': + if (arg.val && typeof arg.val === 'object' && !Array.isArray(arg.val)) { + const response = arg.val as { type?: string; val?: ClarityValueJson }; + if (response.type === 'ok' && response.val !== undefined) { + return responseOkCV(this.parseCv(response.val)); + } + if (response.type === 'err' && response.val !== undefined) { + return responseErrorCV(this.parseCv(response.val)); + } + } + throw new InvalidParameterValueError('response requires { type: ok|err, val }'); case 'tuple': if (arg.val instanceof Array) { const data = {}; @@ -113,6 +134,23 @@ export class ContractBuilder extends AbstractContractBuilder { return tupleCV(data); } throw new InvalidParameterValueError('tuple require Array val'); + case 'contractPrincipal': + case 'contract-principal': { + if (typeof arg.val !== 'string') { + throw new InvalidParameterValueError('contract principal requires string val'); + } + const separator = arg.val.indexOf('.'); + if (separator <= 0 || separator === arg.val.length - 1 || arg.val.indexOf('.', separator + 1) !== -1) { + throw new InvalidParameterValueError('contract principal must have address.contract-name format'); + } + return contractPrincipalCV(arg.val.slice(0, separator), arg.val.slice(separator + 1)); + } + case 'standardPrincipal': + case 'standard-principal': + if (typeof arg.val !== 'string') { + throw new InvalidParameterValueError('standard principal requires string val'); + } + return standardPrincipalCV(arg.val); case 'buffer': if (arg.val instanceof Buffer) { return bufferCV(arg.val); diff --git a/modules/sdk-coin-stx/src/lib/index.ts b/modules/sdk-coin-stx/src/lib/index.ts index 4850485801..a477e81212 100644 --- a/modules/sdk-coin-stx/src/lib/index.ts +++ b/modules/sdk-coin-stx/src/lib/index.ts @@ -4,5 +4,7 @@ export * from './transaction'; export * from './transactionBuilderFactory'; export * from './sbtcWithdrawBuilder'; export * from './btcAddressUtils'; +export * from './pox5Builder'; +export * from './constants'; export * from './iface'; export * as Utils from './utils'; diff --git a/modules/sdk-coin-stx/src/lib/pox5Builder.ts b/modules/sdk-coin-stx/src/lib/pox5Builder.ts new file mode 100644 index 0000000000..a6bfe189fa --- /dev/null +++ b/modules/sdk-coin-stx/src/lib/pox5Builder.ts @@ -0,0 +1,253 @@ +import { BaseCoin as CoinConfig, StacksNetwork as BitgoStacksNetwork } from '@bitgo/statics'; +import { + bufferCV, + contractPrincipalCV, + ContractCallPayload, + ClarityValue, + listCV, + noneCV, + responseErrorCV, + responseOkCV, + someCV, + standardPrincipalCV, + tupleCV, + uintCV, +} from '@stacks/transactions'; +import { InvalidParameterValueError } from '@bitgo/sdk-core'; +import { ContractBuilder } from './contractBuilder'; +import { + CONTRACT_NAME_POX5, + FUNCTION_NAME_ANNOUNCE_L1_EARLY_EXIT, + FUNCTION_NAME_CALCULATE_REWARDS, + FUNCTION_NAME_CLAIM_REWARDS, + FUNCTION_NAME_CLAIM_STAKER_REWARDS, + FUNCTION_NAME_REGISTER_FOR_BOND, + FUNCTION_NAME_STAKE, + FUNCTION_NAME_STAKE_UPDATE, + FUNCTION_NAME_UNSTAKE, + FUNCTION_NAME_UPDATE_BOND_REGISTRATION, +} from './constants'; + +type Integer = bigint | number | string; +type ByteValue = Buffer | Uint8Array | string; + +export interface Pox5LockupOutput { + height: number; + tx: ByteValue; + outputIndex: number; + header: ByteValue; + leafHashes: ByteValue[]; + txCount: number; + txIndex: number; + amount: Integer; + unlockBurnHeight: number; +} + +export type Pox5Lockup = + | { + kind: 'btc'; + outputs: Pox5LockupOutput[]; + unlockBytes: ByteValue; + } + | { + kind: 'sbtc'; + sbtcSats: Integer; + }; + +export interface Pox5RegisterForBondParams { + bondIndex: Integer; + signerManager: string; + amountUstx: Integer; + lockup: Pox5Lockup; + signerCalldata?: ByteValue; +} + +export interface Pox5UpdateBondRegistrationParams { + signerManager: string; + oldSignerManager: string; + signerCalldata?: ByteValue; +} + +export interface Pox5AnnounceL1EarlyExitParams { + staker: string; + oldSignerManager: string; +} + +export interface Pox5StakeParams { + signerManager: string; + amountUstx: Integer; + numCycles: Integer; + startBurnHt: Integer; + signerCalldata?: ByteValue; +} + +export interface Pox5StakeUpdateParams { + signerManager: string; + oldSignerManager: string; + cyclesToExtend?: Integer; + amountIncrease?: Integer; + signerCalldata?: ByteValue; +} + +export interface Pox5ClaimRewardsParams { + bondIndices: Integer[]; + rewardCycle: Integer; +} + +export interface Pox5ClaimStakerRewardsParams { + staker: string; + rewardCycle: Integer; + bondIndex?: Integer; +} + +function byteBuffer(value: ByteValue, field: string): Buffer { + if (Buffer.isBuffer(value)) { + return value; + } + if (value instanceof Uint8Array) { + return Buffer.from(value); + } + const hex = value.startsWith('0x') ? value.slice(2) : value; + if (hex.length % 2 !== 0 || !/^[0-9a-f]*$/i.test(hex)) { + throw new InvalidParameterValueError(`${field} must be an even-length hexadecimal string`); + } + return Buffer.from(hex, 'hex'); +} + +function contractPrincipal(value: string): ClarityValue { + const separator = value.indexOf('.'); + if (separator <= 0 || separator === value.length - 1 || value.indexOf('.', separator + 1) !== -1) { + throw new InvalidParameterValueError(`${value} must have address.contract-name format`); + } + return contractPrincipalCV(value.slice(0, separator), value.slice(separator + 1)); +} + +function optionalBuffer(value: ByteValue | undefined): ClarityValue { + return value === undefined ? noneCV() : someCV(bufferCV(byteBuffer(value, 'signerCalldata'))); +} + +function lockupValue(lockup: Pox5Lockup): ClarityValue { + if (lockup.kind === 'sbtc') { + return responseErrorCV(uintCV(lockup.sbtcSats)); + } + if (lockup.outputs.length === 0 || lockup.outputs.length > 10) { + throw new InvalidParameterValueError('btc lockup outputs must contain between 1 and 10 outputs'); + } + for (const [index, output] of lockup.outputs.entries()) { + if (output.leafHashes.length > 14) { + throw new InvalidParameterValueError(`btc lockup output ${index} has more than 14 merkle siblings`); + } + } + return responseOkCV( + tupleCV({ + outputs: listCV( + lockup.outputs.map((output) => + tupleCV({ + height: uintCV(output.height), + tx: bufferCV(byteBuffer(output.tx, 'tx')), + 'output-index': uintCV(output.outputIndex), + header: bufferCV(byteBuffer(output.header, 'header')), + 'leaf-hashes': listCV(output.leafHashes.map((hash) => bufferCV(byteBuffer(hash, 'leafHash')))), + 'tx-count': uintCV(output.txCount), + 'tx-index': uintCV(output.txIndex), + amount: uintCV(output.amount), + 'unlock-burn-height': uintCV(output.unlockBurnHeight), + }) + ) + ), + 'staker-unlock-bytes': bufferCV(byteBuffer(lockup.unlockBytes, 'unlockBytes')), + }) + ); +} + +export class Pox5Builder extends ContractBuilder { + constructor(coinConfig: Readonly) { + super(coinConfig); + this._contractAddress = (coinConfig.network as BitgoStacksNetwork).stakingContractAddress; + this._contractName = CONTRACT_NAME_POX5; + } + + public static isValidContractCall(payload: ContractCallPayload): boolean { + return payload.contractName.content === CONTRACT_NAME_POX5; + } + + registerForBond(params: Pox5RegisterForBondParams): this { + this.functionName(FUNCTION_NAME_REGISTER_FOR_BOND); + this.functionArgs([ + uintCV(params.bondIndex), + contractPrincipal(params.signerManager), + uintCV(params.amountUstx), + lockupValue(params.lockup), + optionalBuffer(params.signerCalldata), + ]); + return this; + } + + updateBondRegistration(params: Pox5UpdateBondRegistrationParams): this { + this.functionName(FUNCTION_NAME_UPDATE_BOND_REGISTRATION); + this.functionArgs([ + contractPrincipal(params.signerManager), + contractPrincipal(params.oldSignerManager), + optionalBuffer(params.signerCalldata), + ]); + return this; + } + + announceL1EarlyExit(params: Pox5AnnounceL1EarlyExitParams): this { + this.functionName(FUNCTION_NAME_ANNOUNCE_L1_EARLY_EXIT); + this.functionArgs([standardPrincipalCV(params.staker), contractPrincipal(params.oldSignerManager)]); + return this; + } + + stake(params: Pox5StakeParams): this { + this.functionName(FUNCTION_NAME_STAKE); + this.functionArgs([ + contractPrincipal(params.signerManager), + uintCV(params.amountUstx), + uintCV(params.numCycles), + uintCV(params.startBurnHt), + optionalBuffer(params.signerCalldata), + ]); + return this; + } + + stakeUpdate(params: Pox5StakeUpdateParams): this { + this.functionName(FUNCTION_NAME_STAKE_UPDATE); + this.functionArgs([ + contractPrincipal(params.signerManager), + contractPrincipal(params.oldSignerManager), + uintCV(params.cyclesToExtend ?? 0), + uintCV(params.amountIncrease ?? 0), + optionalBuffer(params.signerCalldata), + ]); + return this; + } + + unstake(oldSignerManager: string): this { + this.functionName(FUNCTION_NAME_UNSTAKE); + this.functionArgs([contractPrincipal(oldSignerManager)]); + return this; + } + + calculateRewards(bondIndices: Integer[]): this { + this.functionName(FUNCTION_NAME_CALCULATE_REWARDS); + this.functionArgs([listCV(bondIndices.map((bondIndex) => uintCV(bondIndex)))]); + return this; + } + + claimRewards(params: Pox5ClaimRewardsParams): this { + this.functionName(FUNCTION_NAME_CLAIM_REWARDS); + this.functionArgs([listCV(params.bondIndices.map((bondIndex) => uintCV(bondIndex))), uintCV(params.rewardCycle)]); + return this; + } + + claimStakerRewardsForSigner(params: Pox5ClaimStakerRewardsParams): this { + this.functionName(FUNCTION_NAME_CLAIM_STAKER_REWARDS); + this.functionArgs([ + standardPrincipalCV(params.staker), + uintCV(params.rewardCycle), + params.bondIndex === undefined ? noneCV() : someCV(uintCV(params.bondIndex)), + ]); + return this; + } +} diff --git a/modules/sdk-coin-stx/src/lib/transactionBuilderFactory.ts b/modules/sdk-coin-stx/src/lib/transactionBuilderFactory.ts index 54bc96874f..c2e2dd6a89 100644 --- a/modules/sdk-coin-stx/src/lib/transactionBuilderFactory.ts +++ b/modules/sdk-coin-stx/src/lib/transactionBuilderFactory.ts @@ -14,6 +14,7 @@ import { Utils } from '.'; import { SendmanyBuilder } from './sendmanyBuilder'; import { SbtcWithdrawBuilder } from './sbtcWithdrawBuilder'; import { FungibleTokenTransferBuilder } from './fungibleTokenTransferBuilder'; +import { Pox5Builder } from './pox5Builder'; export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { constructor(_coinConfig: Readonly) { @@ -38,6 +39,9 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { if (FungibleTokenTransferBuilder.isFungibleTokenTransferContractCall(tx.stxTransaction.payload)) { return this.getFungibleTokenTransferBuilder(tx); } + if (Pox5Builder.isValidContractCall(tx.stxTransaction.payload)) { + return this.getPox5Builder(tx); + } return this.getContractBuilder(tx); default: throw new InvalidTransactionError('Invalid transaction'); @@ -83,6 +87,10 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { return TransactionBuilderFactory.initializeBuilder(new FungibleTokenTransferBuilder(this._coinConfig), tx); } + getPox5Builder(tx?: Transaction): Pox5Builder { + return TransactionBuilderFactory.initializeBuilder(new Pox5Builder(this._coinConfig), tx); + } + /** * Initialize the builder with the given transaction * diff --git a/modules/sdk-coin-stx/src/lib/utils.ts b/modules/sdk-coin-stx/src/lib/utils.ts index 577265d576..c7f83a6f4a 100644 --- a/modules/sdk-coin-stx/src/lib/utils.ts +++ b/modules/sdk-coin-stx/src/lib/utils.ts @@ -34,7 +34,13 @@ import { InvalidTransactionError, isValidXprv, isValidXpub, SigningError, UtilsE import { AddressDetails, SendParams, TokenTransferParams } from './iface'; import { KeyPair } from '.'; import { coins, Sip10Token, StacksNetwork as BitgoStacksNetwork } from '@bitgo/statics'; -import { VALID_CONTRACT_FUNCTION_NAMES } from './constants'; +import { + CONTRACT_NAME_SENDMANY, + CONTRACT_NAME_STAKING, + CONTRACT_NAME_STAKING_POX5, + VALID_CONTRACT_FUNCTION_NAMES, + VALID_POX5_CONTRACT_FUNCTION_NAMES, +} from './constants'; /** * Encodes a buffer as a "0x" prefixed lower-case hex string. @@ -255,7 +261,13 @@ export function isValidContractAddress(addr: string, network: BitgoStacksNetwork * @param {string} name - function name * @returns {boolean} - validation result */ -export function isValidContractFunctionName(name: string): boolean { +export function isValidContractFunctionName(name: string, contractName?: string): boolean { + if (contractName === CONTRACT_NAME_STAKING_POX5) { + return VALID_POX5_CONTRACT_FUNCTION_NAMES.includes(name); + } + if (contractName === CONTRACT_NAME_STAKING || contractName === CONTRACT_NAME_SENDMANY) { + return VALID_CONTRACT_FUNCTION_NAMES.includes(name) && !VALID_POX5_CONTRACT_FUNCTION_NAMES.includes(name); + } return VALID_CONTRACT_FUNCTION_NAMES.includes(name); } diff --git a/modules/sdk-coin-stx/test/unit/transactionBuilder/contractBuilder.ts b/modules/sdk-coin-stx/test/unit/transactionBuilder/contractBuilder.ts index 191ba59c0c..7ef1c9a4b6 100644 --- a/modules/sdk-coin-stx/test/unit/transactionBuilder/contractBuilder.ts +++ b/modules/sdk-coin-stx/test/unit/transactionBuilder/contractBuilder.ts @@ -144,6 +144,30 @@ describe('Stacks: Contract Builder', function () { tx.inputs[0].value.should.equal('0'); }); + it('an unsigned PoX-5 register-for-bond contract call transaction', async () => { + const builder = factory.getContractBuilder(); + builder.fee({ fee: '180' }); + builder.nonce(0); + builder.contractAddress(testData.CONTRACT_ADDRESS); + builder.contractName('pox-5'); + builder.functionName('register-for-bond'); + builder.functionArgs([ + { type: 'uint128', val: '210' }, + { type: 'principal', val: 'STDE7Y8HV3RX8VBM2TZVWJTS7ZA1XB0SSC3NEVH0.signer-manager' }, + { type: 'uint128', val: '1005000' }, + { type: 'optional' }, + { type: 'optional' }, + ]); + builder.fromPubKey(testData.TX_SENDER.pub); + builder.numberSignatures(1); + + const tx = await builder.build(); + const txJson = tx.toJson(); + should.deepEqual(txJson.payload.contractName, 'pox-5'); + should.deepEqual(txJson.payload.functionName, 'register-for-bond'); + txJson.payload.functionArgs.length.should.equal(5); + }); + it('a signed contract call with args', async () => { const builder = initTxBuilder(); builder.functionArgs([ @@ -408,11 +432,17 @@ describe('Stacks: Contract Builder', function () { }); it('a contract call with an invalid contract name pox-2', () => { const builder = initTxBuilder(); - assert.throws(() => builder.contractName('pox-2'), /Only pox-4 and send-many-memo contracts supported/); + assert.throws( + () => builder.contractName('pox-2'), + /Only pox-4, pox-5, and send-many-memo contracts supported/ + ); }); it('a contract call with an invalid contract name pox-3', () => { const builder = initTxBuilder(); - assert.throws(() => builder.contractName('pox-3'), /Only pox-4 and send-many-memo contracts supported/); + assert.throws( + () => builder.contractName('pox-3'), + /Only pox-4, pox-5, and send-many-memo contracts supported/ + ); }); it('a contract call with an invalid contract function name', () => { const builder = initTxBuilder(); @@ -421,6 +451,14 @@ describe('Stacks: Contract Builder', function () { new RegExp('test-function is not supported contract function name') ); }); + it('rejects a PoX-4 function on a PoX-5 contract', () => { + const builder = initTxBuilder(); + builder.contractName('pox-5'); + assert.throws( + () => builder.functionName('stack-stx'), + new RegExp('stack-stx is not supported contract function name') + ); + }); }); }); }); diff --git a/modules/sdk-coin-stx/test/unit/transactionBuilder/pox5Builder.ts b/modules/sdk-coin-stx/test/unit/transactionBuilder/pox5Builder.ts new file mode 100644 index 0000000000..4f62d0a731 --- /dev/null +++ b/modules/sdk-coin-stx/test/unit/transactionBuilder/pox5Builder.ts @@ -0,0 +1,146 @@ +import { ClarityType, cvToString, cvToValue } from '@stacks/transactions'; +import { coins } from '@bitgo/statics'; +import should from 'should'; + +import { StxLib } from '../../../src'; +import * as testData from '../resources'; + +describe('Stacks: PoX-5 Builder', function () { + const factory = new StxLib.TransactionBuilderFactory(coins.get('tstx')); + const signerManager = 'STDE7Y8HV3RX8VBM2TZVWJTS7ZA1XB0SSC3NEVH0.signer-manager'; + const oldSignerManager = 'STDE7Y8HV3RX8VBM2TZVWJTS7ZA1XB0SSC3NEVH0.old-signer-manager'; + + function configure(builder: StxLib.Pox5Builder): StxLib.Pox5Builder { + builder.fee({ fee: '180' }); + builder.nonce(0); + builder.fromPubKey(testData.TX_SENDER.pub); + builder.numberSignatures(1); + return builder; + } + + it('builds and parses register-for-bond with an L1 lockup', async () => { + const builder = configure(factory.getPox5Builder()); + builder.registerForBond({ + bondIndex: 210, + signerManager, + amountUstx: '1005000', + lockup: { + kind: 'btc', + unlockBytes: '00', + outputs: [ + { + height: 9231, + tx: '00', + outputIndex: 0, + header: '00', + leafHashes: ['00'], + txCount: 1, + txIndex: 0, + amount: 10000, + unlockBurnHeight: 9490, + }, + ], + }, + signerCalldata: '00', + }); + + const tx = await builder.build(); + const payload = tx.toJson().payload as any; + should.equal(payload.functionName, 'register-for-bond'); + should.equal(payload.functionArgs.length, 5); + should.equal(payload.functionArgs[0].type, ClarityType.UInt); + should.equal(payload.functionArgs[1].type, ClarityType.PrincipalContract); + should.equal(payload.functionArgs[3].type, ClarityType.ResponseOk); + should.equal(payload.functionArgs[4].type, ClarityType.OptionalSome); + + const rebuiltBuilder = factory.from(tx.toBroadcastFormat()); + rebuiltBuilder.fromPubKey(testData.TX_SENDER.pub); + const rebuilt = await rebuiltBuilder.build(); + should.equal(rebuilt.toBroadcastFormat(), tx.toBroadcastFormat()); + }); + + it('builds sBTC registration using response error', async () => { + const builder = configure(factory.getPox5Builder()); + builder.registerForBond({ + bondIndex: 210, + signerManager, + amountUstx: '1005000', + lockup: { kind: 'sbtc', sbtcSats: 10000 }, + }); + + const tx = await builder.build(); + const payload = tx.toJson().payload as any; + should.equal(payload.functionArgs[3].type, ClarityType.ResponseErr); + should.equal(cvToValue((builder as any)._functionArgs[3].value).toString(), '10000'); + }); + + it('builds validator update, early exit, and reward calls', async () => { + const update = configure(factory.getPox5Builder()).updateBondRegistration({ + signerManager, + oldSignerManager, + signerCalldata: '00', + }); + should.equal( + ((await update.build()).toJson().payload as { functionName: string }).functionName, + 'update-bond-registration' + ); + + const earlyExit = configure(factory.getPox5Builder()).announceL1EarlyExit({ + staker: testData.TX_SENDER.address, + oldSignerManager, + }); + const earlyExitPayload = (await earlyExit.build()).toJson().payload as any; + should.equal(earlyExitPayload.functionName, 'announce-l1-early-exit'); + should.equal(cvToString((earlyExit as any)._functionArgs[0]), testData.TX_SENDER.address); + + const claims = configure(factory.getPox5Builder()).claimRewards({ + bondIndices: [210, 226], + rewardCycle: 391, + }); + const claimPayload = (await claims.build()).toJson().payload as any; + should.equal(claimPayload.functionName, 'claim-rewards'); + should.equal(claimPayload.functionArgs[0].type, ClarityType.List); + should.equal(claimPayload.functionArgs[1].type, ClarityType.UInt); + }); + + it('accepts every supported PoX-5 function name', () => { + const functionNames = [ + 'stake', + 'stake-update', + 'unstake', + 'register-for-bond', + 'update-bond-registration', + 'announce-l1-early-exit', + 'claim-rewards', + 'claim-staker-rewards-for-signer', + 'calculate-rewards', + ]; + + for (const functionName of functionNames) { + configure(factory.getPox5Builder()).functionName(functionName); + } + }); + + it('parses response, list, and explicit principal JSON values', () => { + const builder = configure(factory.getPox5Builder()); + builder.functionName('calculate-rewards'); + builder.functionArgs([ + { + type: 'response', + val: { + type: 'ok', + val: { + type: 'list', + val: [{ type: 'uint128', val: '210' }], + }, + }, + }, + { type: 'contract-principal', val: oldSignerManager }, + ]); + + const args = (builder as any)._functionArgs; + should.equal(args[0].type, ClarityType.ResponseOk); + should.equal(args[0].value.type, ClarityType.List); + should.equal(args[1].type, ClarityType.PrincipalContract); + }); +}); diff --git a/modules/sdk-core/src/bitgo/staking/iStakingWallet.ts b/modules/sdk-core/src/bitgo/staking/iStakingWallet.ts index b54d3a802d..1c9eef834c 100644 --- a/modules/sdk-core/src/bitgo/staking/iStakingWallet.ts +++ b/modules/sdk-core/src/bitgo/staking/iStakingWallet.ts @@ -33,6 +33,15 @@ export type BabylonParams = { rewardAddress: string; }; +export interface Pox5StakeOptions extends StakeOptions { + subType?: 'pox5-bond'; + bondIndex: number; + signerManager: string; + numCycles?: string; + startBurnHt?: string; + signerCalldata?: string; +} + /** * Represents the options for staking. * @typedef {Object} StakeOptions @@ -49,7 +58,7 @@ export type BabylonParams = { * @property {DelegationRequest[]} [delegationRequests] - The delegation requests * TODO: remove support to this contract version after STX fork * https://bitgoinc.atlassian.net/browse/EA-3482 - * @property {string} [contractName] - stx contract name: valid names are pox-3 and pox-4 only, used only for backward compatibility during nakamoto fork + * @property {string} [contractName] - stx contract name: valid names are pox-3, pox-4 and pox-5 only, used only for backward compatibility during pox contract forks */ export interface StakeOptions { @@ -112,9 +121,9 @@ export interface StakeOptions { // TODO: remove support to this contract version after STX fork // https://bitgoinc.atlassian.net/browse/EA-3482 /** - * pox-contract name (valid values are pox-3 and pox-4) + * pox-contract name (valid values are pox-3, pox-4 and pox-5) */ - contractName?: 'pox-3' | 'pox-4'; + contractName?: 'pox-3' | 'pox-4' | 'pox-5'; /** * btc staking expire time @@ -325,7 +334,14 @@ export interface IStakingWallet { readonly walletId: string; readonly coin: string; stake( - options: StakeOptions | TronStakeOptions | TaoStakeOptions | VetStakeOptions | StoryStakeOptions | XdcStakeOptions + options: + | StakeOptions + | Pox5StakeOptions + | TronStakeOptions + | TaoStakeOptions + | VetStakeOptions + | StoryStakeOptions + | XdcStakeOptions ): Promise; unstake(options: UnstakeOptions | EthUnstakeOptions): Promise; switchValidator( diff --git a/modules/sdk-core/src/bitgo/staking/stakingWallet.ts b/modules/sdk-core/src/bitgo/staking/stakingWallet.ts index 1ce798e2a8..540ca94ec2 100644 --- a/modules/sdk-core/src/bitgo/staking/stakingWallet.ts +++ b/modules/sdk-core/src/bitgo/staking/stakingWallet.ts @@ -27,6 +27,7 @@ import { VetStakeOptions, StoryStakeOptions, XdcStakeOptions, + Pox5StakeOptions, } from './iStakingWallet'; import { BitGoBase } from '../bitgoBase'; import { IWallet, PrebuildTransactionResult } from '../wallet'; @@ -65,7 +66,14 @@ export class StakingWallet implements IStakingWallet { * @return StakingRequest */ async stake( - options: StakeOptions | TronStakeOptions | TaoStakeOptions | VetStakeOptions | StoryStakeOptions | XdcStakeOptions + options: + | StakeOptions + | Pox5StakeOptions + | TronStakeOptions + | TaoStakeOptions + | VetStakeOptions + | StoryStakeOptions + | XdcStakeOptions ): Promise { return await this.createStakingRequest(options, 'STAKE'); } @@ -325,6 +333,7 @@ export class StakingWallet implements IStakingWallet { | EthUnstakeOptions | SwitchValidatorOptions | ClaimRewardsOptions + | Pox5StakeOptions | TronStakeOptions | TaoStakeOptions | TaoSwitchValidatorOptions