Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions modules/sdk-coin-stx/src/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,6 @@ export * from './keyPair';
export * from './transaction';
export * from './transactionBuilderFactory';
export * from './sbtcWithdrawBuilder';
export * from './btcAddressUtils';
export * from './iface';
export * as Utils from './utils';
29 changes: 29 additions & 0 deletions modules/sdk-coin-stx/src/lib/sbtcWithdrawBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,35 @@ export class SbtcWithdrawBuilder extends AbstractContractBuilder {
this._isDeserialized = true;
}

/**
* Get the withdrawal params decoded from the deserialized/built transaction, including the
* raw sBTC recipient version and hash bytes (as opposed to a btcAddress string, which cannot
* be recovered from the on-chain args alone).
*/
getWithdrawParams():
| { amount: string; maxFee: string; recipientVersion: number; recipientHashBytes: Buffer }
| undefined {
if (!this._withdrawParams) {
return undefined;
}
const payload = this.transaction.stxTransaction.payload as ContractCallPayload;
const recipientTuple = payload.functionArgs[1];
if (recipientTuple?.type !== ClarityType.Tuple) {
return undefined;
}
const versionBuf = recipientTuple.data['version'];
const hashbytesBuf = recipientTuple.data['hashbytes'];
if (versionBuf?.type !== ClarityType.Buffer || hashbytesBuf?.type !== ClarityType.Buffer) {
return undefined;
}
return {
amount: this._withdrawParams.amount,
maxFee: this._withdrawParams.maxFee,
recipientVersion: versionBuf.buffer[0],
recipientHashBytes: Buffer.from(hashbytesBuf.buffer),
};
}

/** @inheritdoc */
protected async buildImplementation(): Promise<Transaction> {
if (!this._withdrawParams) {
Expand Down
54 changes: 54 additions & 0 deletions modules/sdk-coin-stx/src/stx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
MultisigType,
multisigTypes,
SignedTransaction,
TransactionParams,
TransactionRecipient,
TransactionType,
VerifyAddressOptions,
Expand Down Expand Up @@ -114,6 +115,11 @@ export class Stx extends BaseCoin {
if (!rawTx) {
throw new Error('missing required tx prebuild property txHex');
}

if (this.hasSbtcWithdrawParams(txParams)) {
return this.verifySbtcWithdrawTransaction(rawTx, txParams.sbtcWithdrawParams);
}

const explainedTx = await this.explainTransaction({ txHex: rawTx, feeInfo: { fee: '' } });
const recipient = txParams.recipients?.[0];
if (recipient !== undefined && explainedTx) {
Expand Down Expand Up @@ -149,6 +155,54 @@ export class Stx extends BaseCoin {
return true;
}

private hasSbtcWithdrawParams(
txParams: TransactionParams
): txParams is TransactionParams & { sbtcWithdrawParams: StxLib.SbtcWithdrawParams } {
return 'sbtcWithdrawParams' in txParams && txParams.sbtcWithdrawParams !== undefined;
}

/**
* Verify an sBTC withdrawal (burn) transaction matches the expected withdrawal params.
*
* @param rawTx - the raw (built) transaction hex from txPrebuild
* @param expected - the sbtcWithdrawParams supplied by the caller in txParams
*/
private async verifySbtcWithdrawTransaction(rawTx: string, expected: StxLib.SbtcWithdrawParams): Promise<boolean> {
const factory = new StxLib.TransactionBuilderFactory(coins.get(this.getChain()));
const builder = factory.from(rawTx);
if (!(builder instanceof StxLib.SbtcWithdrawBuilder)) {
throw new Error('Tx is not a valid sBTC withdrawal transaction');
}

const actual = builder.getWithdrawParams();
if (!actual) {
throw new Error('Unable to parse sBTC withdrawal params from tx');
}

if (BigInt(actual.amount) !== BigInt(expected.amount)) {
throw new Error(
`Tx sBTC withdrawal amount does not match expected amount: expected ${expected.amount} but got ${actual.amount}`
);
}
if (BigInt(actual.maxFee) !== BigInt(expected.maxFee)) {
throw new Error(
`Tx sBTC withdrawal maxFee does not match expected maxFee: expected ${expected.maxFee} but got ${actual.maxFee}`
);
}

const decodedExpected = StxLib.decodeBtcAddress(expected.btcAddress);
if (
decodedExpected.version !== actual.recipientVersion ||
!decodedExpected.hashBytes.equals(actual.recipientHashBytes)
) {
throw new Error(
`Tx sBTC withdrawal btcAddress does not match expected btcAddress: expected ${expected.btcAddress}`
);
}

return true;
}

/**
* Check if address is valid, then make sure it matches the base address.
*
Expand Down
85 changes: 84 additions & 1 deletion modules/sdk-coin-stx/test/unit/stx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@ import { BitGoAPI } from '@bitgo/sdk-api';
import { Wallet } from '@bitgo/sdk-core';
import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test';
import { coins } from '@bitgo/statics';
import { cvToString } from '@stacks/transactions';
import { cvToString, pubKeyfromPrivKey, publicKeyToString } from '@stacks/transactions';

import * as testData from '../fixtures';
import * as resources from './resources';
import { Stx, StxLib, Tstx } from '../../src';
import { RecoveryInfo, RecoveryOptions, RecoveryTransaction } from '../../src/lib/iface';

Expand Down Expand Up @@ -431,6 +432,88 @@ describe('STX:', function () {
});
});

describe('Verify sBTC Withdraw Transaction', function () {
const factory = new StxLib.TransactionBuilderFactory(coins.get('tstx'));
const prvKeysString = resources.prvKeysString.slice(0, 2);

const buildWithdrawTx = async (withdrawParams: { amount: string; btcAddress: string; maxFee: string }) => {
const builder = factory.getSbtcWithdrawBuilder();
builder.fee({ fee: '1000' });
builder.nonce(1);
const pubKeys = prvKeysString.map((prv) => publicKeyToString(pubKeyfromPrivKey(prv)));
builder.fromPubKey(pubKeys);
builder.numberSignatures(2);
builder.withdraw(withdrawParams);
builder.sign({ key: prvKeysString[0] });
builder.sign({ key: prvKeysString[1] });
const tx = await builder.build();
return tx.toBroadcastFormat();
};

it('should succeed to verify a matching sBTC withdrawal', async function () {
const withdrawParams = {
amount: '1000',
btcAddress: 'bc1prxl88w47srqh703pxv567q47e7epzume4nlz8cgewfhtuenn8ngqgwm80w',
maxFee: '10000',
};
const txHex = await buildWithdrawTx(withdrawParams);
const txParams = {
sbtcWithdrawParams: withdrawParams,
recipients: [{ address: 'SM1K9VF5GN48Q0AC2C7SB8WM5N5NR6DYC9VM3QEJE', amount: '10' }],
};
const result = await basecoin.verifyTransaction({ txPrebuild: { txHex }, txParams });
result.should.equal(true);
});

it('should fail to verify with wrong amount', async function () {
const withdrawParams = {
amount: '1000',
btcAddress: 'bc1prxl88w47srqh703pxv567q47e7epzume4nlz8cgewfhtuenn8ngqgwm80w',
maxFee: '10000',
};
const txHex = await buildWithdrawTx(withdrawParams);
const txParams = {
sbtcWithdrawParams: { ...withdrawParams, amount: '9999' },
recipients: [{ address: 'SM1K9VF5GN48Q0AC2C7SB8WM5N5NR6DYC9VM3QEJE', amount: '10' }],
};
await basecoin
.verifyTransaction({ txPrebuild: { txHex }, txParams })
.should.be.rejectedWith(/sBTC withdrawal amount does not match/);
});

it('should fail to verify with wrong maxFee', async function () {
const withdrawParams = {
amount: '1000',
btcAddress: 'bc1prxl88w47srqh703pxv567q47e7epzume4nlz8cgewfhtuenn8ngqgwm80w',
maxFee: '10000',
};
const txHex = await buildWithdrawTx(withdrawParams);
const txParams = {
sbtcWithdrawParams: { ...withdrawParams, maxFee: '1' },
recipients: [{ address: 'SM1K9VF5GN48Q0AC2C7SB8WM5N5NR6DYC9VM3QEJE', amount: '10' }],
};
await basecoin
.verifyTransaction({ txPrebuild: { txHex }, txParams })
.should.be.rejectedWith(/sBTC withdrawal maxFee does not match/);
});

it('should fail to verify with wrong btcAddress', async function () {
const withdrawParams = {
amount: '1000',
btcAddress: 'bc1prxl88w47srqh703pxv567q47e7epzume4nlz8cgewfhtuenn8ngqgwm80w',
maxFee: '10000',
};
const txHex = await buildWithdrawTx(withdrawParams);
const txParams = {
sbtcWithdrawParams: { ...withdrawParams, btcAddress: '1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2' },
recipients: [{ address: 'SM1K9VF5GN48Q0AC2C7SB8WM5N5NR6DYC9VM3QEJE', amount: '10' }],
};
await basecoin
.verifyTransaction({ txPrebuild: { txHex }, txParams })
.should.be.rejectedWith(/sBTC withdrawal btcAddress does not match/);
});
});

describe('Recover Transaction STX', function () {
before(function () {
nock.enableNetConnect();
Expand Down
Loading