diff --git a/modules/sdk-coin-stx/src/lib/constants.ts b/modules/sdk-coin-stx/src/lib/constants.ts index bb53cb24fe..0a75d7ddaa 100644 --- a/modules/sdk-coin-stx/src/lib/constants.ts +++ b/modules/sdk-coin-stx/src/lib/constants.ts @@ -1,10 +1,34 @@ 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 VALID_STAKING_CONTRACT_NAMES = [CONTRACT_NAME_STAKING, CONTRACT_NAME_POX5]; 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', diff --git a/modules/sdk-coin-stx/src/lib/contractBuilder.ts b/modules/sdk-coin-stx/src/lib/contractBuilder.ts index 71b4855f2b..94ec7582d9 100644 --- a/modules/sdk-coin-stx/src/lib/contractBuilder.ts +++ b/modules/sdk-coin-stx/src/lib/contractBuilder.ts @@ -7,15 +7,18 @@ import { ClarityValue, encodeClarityValue, noneCV, + listCV, + responseErrorCV, + responseOkCV, someCV, tupleCV, } from '@stacks/transactions'; import { InvalidParameterValueError } from '@bitgo/sdk-core'; import { Transaction } from './transaction'; -import { isValidAddress } from './utils'; +import { contractPrincipalCVFromString, isValidAddress, standardPrincipalCVFromString } 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 +63,11 @@ 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'); + } + if (this._functionName && !Utils.isValidContractFunctionName(this._functionName, name)) { + throw new InvalidParameterValueError(`${this._functionName} is not supported contract function name`); } this._contractName = name; return this; @@ -77,7 +83,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 +110,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 +135,19 @@ 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'); + } + return contractPrincipalCVFromString(arg.val); + } + case 'standardPrincipal': + case 'standard-principal': + if (typeof arg.val !== 'string') { + throw new InvalidParameterValueError('standard principal requires string val'); + } + return standardPrincipalCVFromString(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..2ca71e4d87 100644 --- a/modules/sdk-coin-stx/src/lib/index.ts +++ b/modules/sdk-coin-stx/src/lib/index.ts @@ -4,5 +4,6 @@ export * from './transaction'; export * from './transactionBuilderFactory'; export * from './sbtcWithdrawBuilder'; export * from './btcAddressUtils'; +export * from './pox5Builder'; 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..1a9c9a9977 --- /dev/null +++ b/modules/sdk-coin-stx/src/lib/pox5Builder.ts @@ -0,0 +1,259 @@ +import { BaseCoin as CoinConfig, StacksNetwork as BitgoStacksNetwork } from '@bitgo/statics'; +import { + bufferCV, + ContractCallPayload, + ClarityValue, + listCV, + noneCV, + responseErrorCV, + responseOkCV, + someCV, + tupleCV, + uintCV, + addressToString, +} 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'; +import { contractPrincipalCVFromString, standardPrincipalCVFromString } from './utils'; + +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, expectedLength?: number): Buffer { + // String inputs are hexadecimal, with an optional 0x prefix; use Buffer for text bytes. + let buffer: Buffer; + if (Buffer.isBuffer(value)) { + buffer = value; + } else if (value instanceof Uint8Array) { + buffer = Buffer.from(value); + } else { + 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`); + } + buffer = Buffer.from(hex, 'hex'); + } + if (expectedLength !== undefined && buffer.length !== expectedLength) { + throw new InvalidParameterValueError(`${field} must be exactly ${expectedLength} bytes`); + } + return buffer; +} + +function optionalBuffer(value: ByteValue | undefined): ClarityValue { + return value === undefined ? noneCV() : someCV(bufferCV(byteBuffer(value, 'signerCalldata'))); +} + +function lockupValue(lockup: Pox5Lockup): ClarityValue { + if (lockup.kind === 'sbtc') { + // PoX-5 ABI represents an sBTC lockup as err uint and an L1 BTC lockup as ok tuple. + 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', 80)), + 'leaf-hashes': listCV(output.leafHashes.map((hash) => bufferCV(byteBuffer(hash, 'leafHash', 32)))), + '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(coinConfig: Readonly, payload: ContractCallPayload): boolean { + return ( + (coinConfig.network as BitgoStacksNetwork).stakingContractAddress === addressToString(payload.contractAddress) && + payload.contractName.content === CONTRACT_NAME_POX5 + ); + } + + registerForBond(params: Pox5RegisterForBondParams): this { + this.functionName(FUNCTION_NAME_REGISTER_FOR_BOND); + this.functionArgs([ + uintCV(params.bondIndex), + contractPrincipalCVFromString(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([ + contractPrincipalCVFromString(params.signerManager), + contractPrincipalCVFromString(params.oldSignerManager), + optionalBuffer(params.signerCalldata), + ]); + return this; + } + + announceL1EarlyExit(params: Pox5AnnounceL1EarlyExitParams): this { + this.functionName(FUNCTION_NAME_ANNOUNCE_L1_EARLY_EXIT); + this.functionArgs([ + standardPrincipalCVFromString(params.staker), + contractPrincipalCVFromString(params.oldSignerManager), + ]); + return this; + } + + stake(params: Pox5StakeParams): this { + this.functionName(FUNCTION_NAME_STAKE); + this.functionArgs([ + contractPrincipalCVFromString(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([ + contractPrincipalCVFromString(params.signerManager), + contractPrincipalCVFromString(params.oldSignerManager), + // The PoX-5 ABI uses uint values; omitted values intentionally encode zero. + uintCV(params.cyclesToExtend ?? 0), + uintCV(params.amountIncrease ?? 0), + optionalBuffer(params.signerCalldata), + ]); + return this; + } + + unstake(oldSignerManager: string): this { + this.functionName(FUNCTION_NAME_UNSTAKE); + this.functionArgs([contractPrincipalCVFromString(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([ + standardPrincipalCVFromString(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..a73752f907 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(this._coinConfig, 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..1d723e12da 100644 --- a/modules/sdk-coin-stx/src/lib/utils.ts +++ b/modules/sdk-coin-stx/src/lib/utils.ts @@ -11,6 +11,7 @@ import { BufferReader, ClarityType, ClarityValue, + contractPrincipalCV, createAddress, createMemoString, createMessageSignature, @@ -30,11 +31,45 @@ import { } from '@stacks/transactions'; import { secp256k1 } from '@noble/curves/secp256k1'; import * as _ from 'lodash'; -import { InvalidTransactionError, isValidXprv, isValidXpub, SigningError, UtilsError } from '@bitgo/sdk-core'; +import { + InvalidParameterValueError, + InvalidTransactionError, + isValidXprv, + isValidXpub, + SigningError, + UtilsError, +} from '@bitgo/sdk-core'; 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_POX5, + VALID_CONTRACT_FUNCTION_NAMES, + VALID_POX5_CONTRACT_FUNCTION_NAMES, +} from './constants'; + +/** Convert a contract principal string to a Clarity value with normalized validation errors. */ +export function contractPrincipalCVFromString(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`); + } + const address = value.slice(0, separator); + if (!isValidAddress(address)) { + throw new InvalidParameterValueError(`${value} must contain a valid address`); + } + return contractPrincipalCV(address, value.slice(separator + 1)); +} + +/** Convert a standard principal string to a Clarity value with a normalized validation error. */ +export function standardPrincipalCVFromString(value: string): ClarityValue { + if (!isValidAddress(value)) { + throw new InvalidParameterValueError(`${value} must be a valid standard principal`); + } + return standardPrincipalCV(value); +} /** * Encodes a buffer as a "0x" prefixed lower-case hex string. @@ -255,7 +290,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_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..5f5df40c68 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,45 @@ 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 = factory.getContractBuilder(); + builder.contractName('pox-5'); + assert.throws( + () => builder.functionName('stack-stx'), + new RegExp('stack-stx is not supported contract function name') + ); + }); + it('rejects a PoX-5 function before a contract is selected', () => { + const builder = factory.getContractBuilder(); + assert.throws( + () => builder.functionName('register-for-bond'), + new RegExp('register-for-bond is not supported contract function name') + ); + }); + it('revalidates the function when changing the contract', () => { + const builder = initTxBuilder(); + assert.throws( + () => builder.contractName('pox-5'), + new RegExp('stack-stx is not supported contract function name') + ); + }); + it('rejects malformed principal and nested Clarity values', () => { + const builder = initTxBuilder(); + assert.throws( + () => builder.functionArgs([{ type: 'contract-principal', val: 'no-dot' }]), + /address.contract-name format/ + ); + assert.throws( + () => builder.functionArgs([{ type: 'standard-principal', val: 'invalid-address' }]), + /must be a valid standard principal/ + ); + assert.throws(() => builder.functionArgs([{ type: 'list', val: 'not-an-array' }]), /list requires Array val/); + assert.throws( + () => + builder.functionArgs([{ type: 'response', val: { type: 'maybe', val: { type: 'uint128', val: '1' } } }]), + /response requires \{ type: ok\|err, val \}/ + ); + }); }); }); }); diff --git a/modules/sdk-coin-stx/test/unit/transactionBuilder/fungibleTokenTransferBuilder.ts b/modules/sdk-coin-stx/test/unit/transactionBuilder/fungibleTokenTransferBuilder.ts index 824485d5ab..8370c46eb2 100644 --- a/modules/sdk-coin-stx/test/unit/transactionBuilder/fungibleTokenTransferBuilder.ts +++ b/modules/sdk-coin-stx/test/unit/transactionBuilder/fungibleTokenTransferBuilder.ts @@ -171,6 +171,13 @@ describe('Stacks: Fungible Token Transfer Builder', () => { new RegExp('test-function is not supported contract function name') ); }); + it('rejects PoX-5 functions', () => { + const builder = initTxBuilder(); + assert.throws( + () => builder.functionName('claim-rewards'), + new RegExp('claim-rewards 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..15263c7bf5 --- /dev/null +++ b/modules/sdk-coin-stx/test/unit/transactionBuilder/pox5Builder.ts @@ -0,0 +1,261 @@ +import assert from 'assert'; +import { ClarityType, createAddress, 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; + } + + function validLockupOutput() { + return { + height: 9231, + tx: '00', + outputIndex: 0, + header: '00'.repeat(80), + leafHashes: ['00'.repeat(32)], + txCount: 1, + txIndex: 0, + amount: 10000, + unlockBurnHeight: 9490, + }; + } + + 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'.repeat(80), + leafHashes: ['00'.repeat(32)], + 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('validates the PoX-5 contract address during factory routing', async () => { + const builder = configure(factory.getPox5Builder()); + builder.registerForBond({ + bondIndex: 210, + signerManager, + amountUstx: '1005000', + lockup: { kind: 'btc', unlockBytes: '00', outputs: [validLockupOutput()] }, + }); + const tx = await builder.build(); + const payload = (tx as StxLib.Transaction).stxTransaction.payload as any; + should.equal(StxLib.Pox5Builder.isValidContractCall(coins.get('tstx'), payload), true); + should.equal( + StxLib.Pox5Builder.isValidContractCall(coins.get('tstx'), { + ...payload, + contractAddress: createAddress(testData.ACCOUNT_1.address), + }), + false + ); + }); + + 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(payload.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(earlyExitPayload.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', async () => { + 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 = ((await builder.build()).toJson().payload 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); + }); + + it('rejects invalid SPV proof byte lengths', () => { + const lockup = { + kind: 'btc' as const, + unlockBytes: '00', + outputs: [ + { + height: 9231, + tx: '00', + outputIndex: 0, + header: '00', + leafHashes: ['00'.repeat(32)], + txCount: 1, + txIndex: 0, + amount: 10000, + unlockBurnHeight: 9490, + }, + ], + }; + + assert.throws( + () => + configure(factory.getPox5Builder()).registerForBond({ + bondIndex: 210, + signerManager, + amountUstx: '1005000', + lockup, + }), + /header must be exactly 80 bytes/ + ); + + assert.throws( + () => + configure(factory.getPox5Builder()).registerForBond({ + bondIndex: 210, + signerManager, + amountUstx: '1005000', + lockup: { + ...lockup, + outputs: [{ ...lockup.outputs[0], header: '00'.repeat(80), leafHashes: ['00'] }], + }, + }), + /leafHash must be exactly 32 bytes/ + ); + }); + + it('rejects malformed lockup fields and contract principals', () => { + const validLockup = { kind: 'btc' as const, unlockBytes: '00', outputs: [validLockupOutput()] }; + const register = (lockup: typeof validLockup, manager = signerManager) => + configure(factory.getPox5Builder()).registerForBond({ + bondIndex: 210, + signerManager: manager, + amountUstx: '1005000', + lockup, + }); + + assert.throws(() => register(validLockup, 'no-dot'), /address.contract-name format/); + assert.throws(() => register(validLockup, 'a.b.c'), /address.contract-name format/); + assert.throws(() => register(validLockup, 'STDE7Y8HV3RX8VBM2TZVWJTS7ZA1XB0SSC3NEVH0.'), /address.contract-name/); + assert.throws( + () => register({ ...validLockup, outputs: [{ ...validLockup.outputs[0], tx: '0' }] }), + /tx must be an even-length hexadecimal string/ + ); + assert.throws( + () => register({ ...validLockup, outputs: [{ ...validLockup.outputs[0], tx: 'zz' }] }), + /tx must be an even-length hexadecimal string/ + ); + assert.throws( + () => register({ ...validLockup, outputs: Array.from({ length: 11 }, () => validLockupOutput()) }), + /between 1 and 10 outputs/ + ); + assert.throws( + () => + register({ + ...validLockup, + outputs: [{ ...validLockup.outputs[0], leafHashes: Array(15).fill('00'.repeat(32)) }], + }), + /more than 14 merkle siblings/ + ); + }); +}); diff --git a/modules/sdk-core/src/bitgo/staking/iStakingWallet.ts b/modules/sdk-core/src/bitgo/staking/iStakingWallet.ts index b54d3a802d..ec7c8ef90f 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 Omit { + 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 { @@ -79,9 +88,9 @@ export interface StakeOptions { */ blsSignature?: string; /** - * coin specific staking subtype + * subtype-specific interfaces provide their own discriminant */ - subType?: string; + subType?: never; /** * stx btc reward address */ @@ -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