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
94 changes: 85 additions & 9 deletions modules/sdk-coin-stx/src/lib/contractBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { Transaction } from './transaction';
import { contractPrincipalCVFromString, isValidAddress, standardPrincipalCVFromString } from './utils';
import { ClarityValueJson } from './iface';
import { Utils } from '.';
import { CONTRACT_NAME_SENDMANY, VALID_STAKING_CONTRACT_NAMES } from './constants';
import { CONTRACT_NAME_SENDMANY, VALID_POX5_CONTRACT_FUNCTION_NAMES, VALID_STAKING_CONTRACT_NAMES } from './constants';
import { AbstractContractBuilder } from './abstractContractBuilder';

export class ContractBuilder extends AbstractContractBuilder {
Expand All @@ -30,13 +30,64 @@ export class ContractBuilder extends AbstractContractBuilder {
ClarityType.BoolFalse,
]);

constructor(_coinConfig: Readonly<CoinConfig>) {
/**
* Caller-supplied staking contracts this builder accepts **in addition to** the canonical ones
* (pox-4 / pox-5 / send-many-memo). Empty by default, so behaviour is unchanged unless a caller
* opts in. Lets a deployment target a different staking contract (e.g. a test/dummy pox-5, or a
* future pox version) without an SDK change — the caller passes the name/address it is configured
* with. Threaded in via the constructor so every path that rebuilds a transaction
* (`factory.from(hex, { allowedStakingContracts })`) re-validates against the same rules.
*/
private _allowedStakingContracts: ReadonlyArray<{ name: string; address: string }>;

constructor(
_coinConfig: Readonly<CoinConfig>,
allowedStakingContracts: ReadonlyArray<{ name: string; address: string }> = []
) {
super(_coinConfig);
this._allowedStakingContracts = allowedStakingContracts;
}

allowedStakingContracts(contracts: ReadonlyArray<{ name: string; address: string }>): this {
const list = contracts ?? [];
const names = new Set<string>();
for (const contract of list) {
if (names.has(contract.name)) {
throw new InvalidParameterValueError(`Duplicate staking contract name: ${contract.name}`);
}
names.add(contract.name);
}
this._allowedStakingContracts = list;
return this;
}

/** True when the current (name, address) is a canonical pair or an explicitly allowlisted pair. */
private isContractPairValid(): boolean {
const isCanonicalName =
VALID_STAKING_CONTRACT_NAMES.includes(this._contractName) || this._contractName === CONTRACT_NAME_SENDMANY;
const isCanonicalAddress = Utils.isValidContractAddress(
this._contractAddress,
this._coinConfig.network as BitgoStacksNetwork
);
if (isCanonicalName && isCanonicalAddress) {
return true;
}
return this._allowedStakingContracts.some(
(contract) => contract.name === this._contractName && contract.address === this._contractAddress
);
}

/** True when the name belongs to an allowlisted staking contract. */
private isAllowedStakingContractName(name: string): boolean {
return this._allowedStakingContracts.some((contract) => contract.name === name);
}

initBuilder(tx: Transaction): void {
super.initBuilder(tx);
// Retro compatibility, checks parameters.
// Retro compatibility, checks parameters — run unchanged on every path. The allowlist is threaded
// into this builder (constructor / `factory.from(hex, { allowedStakingContracts })`), so an
// allowlisted contract re-validates against the same rules as on the build path, and a
// non-allowlisted one is still rejected (no relaxation).
this.contractAddress(this._contractAddress);
this.contractName(this._contractName);
this.functionName(this._functionName);
Expand All @@ -54,10 +105,16 @@ export class ContractBuilder extends AbstractContractBuilder {
if (!isValidAddress(address)) {
throw new InvalidParameterValueError('Invalid address');
}
if (!Utils.isValidContractAddress(address, this._coinConfig.network as BitgoStacksNetwork)) {
const isCanonicalAddress = Utils.isValidContractAddress(address, this._coinConfig.network as BitgoStacksNetwork);
const isAllowedAddress = this._allowedStakingContracts.some((contract) => contract.address === address);
if (!isCanonicalAddress && !isAllowedAddress) {
throw new InvalidParameterValueError('Invalid contract address');
}
this._contractAddress = address;
Comment thread
Doddanna17 marked this conversation as resolved.
// Order-independent: if the name is already set, the (name, address) pair must be valid.
if (this._contractName && !this.isContractPairValid()) {
throw new InvalidParameterValueError('Contract name and address are not an allowlisted pair');
}
return this;
}

Expand All @@ -71,13 +128,26 @@ export class ContractBuilder extends AbstractContractBuilder {
if (name.length === 0) {
throw new InvalidParameterValueError('Invalid name');
}
if (!VALID_STAKING_CONTRACT_NAMES.includes(name) && name !== CONTRACT_NAME_SENDMANY) {
const isCanonicalName = VALID_STAKING_CONTRACT_NAMES.includes(name) || name === CONTRACT_NAME_SENDMANY;
if (!isCanonicalName && !this.isAllowedStakingContractName(name)) {
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;
// Order-independent: if the address is already set, the (name, address) pair must be valid.
if (this._contractAddress && !this.isContractPairValid()) {
throw new InvalidParameterValueError('Contract name and address are not an allowlisted pair');
}
// A caller-allowlisted contract (e.g. the dummy pox-5) is ABI-identical to the canonical pox-5,
// so its functions are validated against the pox-5 set instead of being skipped. (A contract
// with a genuinely different ABI would need to carry its own function list.)
if (this._functionName) {
const validFunction = this.isAllowedStakingContractName(name)
? VALID_POX5_CONTRACT_FUNCTION_NAMES.includes(this._functionName)
: Utils.isValidContractFunctionName(this._functionName, name);
if (!validFunction) {
throw new InvalidParameterValueError(`${this._functionName} is not supported contract function name`);
}
}
return this;
}

Expand All @@ -91,7 +161,13 @@ export class ContractBuilder extends AbstractContractBuilder {
if (name.length === 0) {
throw new InvalidParameterValueError('Invalid name');
}
if (!Utils.isValidContractFunctionName(name, this._contractName)) {
if (this.isAllowedStakingContractName(this._contractName)) {
// Caller-allowlisted contract: validate against the canonical pox-5 function set (ABI-identical
// copy) rather than skipping the check.
if (!VALID_POX5_CONTRACT_FUNCTION_NAMES.includes(name)) {
throw new InvalidParameterValueError(`${name} is not supported contract function name`);
}
} else if (!Utils.isValidContractFunctionName(name, this._contractName)) {
throw new InvalidParameterValueError(`${name} is not supported contract function name`);
}
this._functionName = name;
Expand Down
17 changes: 13 additions & 4 deletions modules/sdk-coin-stx/src/lib/transactionBuilderFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory {
}

/** @inheritdoc */
from(raw: string): TransactionBuilder {
from(
raw: string,
params?: { allowedStakingContracts?: ReadonlyArray<{ name: string; address: string }> }
): TransactionBuilder {
this.validateRawTransaction(raw);
try {
const tx = this.parseTransaction(raw);
Expand All @@ -42,7 +45,7 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory {
if (Pox5Builder.isValidContractCall(this._coinConfig, tx.stxTransaction.payload)) {
return this.getPox5Builder(tx);
}
return this.getContractBuilder(tx);
return this.getContractBuilder(tx, params?.allowedStakingContracts);
default:
throw new InvalidTransactionError('Invalid transaction');
}
Expand All @@ -69,8 +72,14 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory {
return TransactionBuilderFactory.initializeBuilder(new TransferBuilder(this._coinConfig), tx);
}

getContractBuilder(tx?: Transaction): ContractBuilder {
return TransactionBuilderFactory.initializeBuilder(new ContractBuilder(this._coinConfig), tx);
getContractBuilder(
tx?: Transaction,
allowedStakingContracts?: ReadonlyArray<{ name: string; address: string }>
): ContractBuilder {
return TransactionBuilderFactory.initializeBuilder(
new ContractBuilder(this._coinConfig, allowedStakingContracts),
tx
);
}

getSendmanyBuilder(tx?: Transaction): SendmanyBuilder {
Expand Down
6 changes: 4 additions & 2 deletions modules/sdk-coin-stx/src/stx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,9 @@ export class Stx extends BaseCoin {
*/
async signTransaction(params: StxSignTransactionOptions): Promise<SignedTransaction> {
const factory = new StxLib.TransactionBuilderFactory(coins.get(this.getChain()));
const txBuilder = factory.from(params.txPrebuild.txHex);
const txBuilder = factory.from(params.txPrebuild.txHex, {
allowedStakingContracts: params.allowedStakingContracts,
});
const prvKeys = params.prv instanceof Array ? params.prv : [params.prv];
prvKeys.forEach((prv) => txBuilder.sign({ key: prv }));
if (params.pubKeys) txBuilder.fromPubKey(params.pubKeys);
Expand Down Expand Up @@ -316,7 +318,7 @@ export class Stx extends BaseCoin {
}

const factory = new StxLib.TransactionBuilderFactory(coins.get(this.getChain()));
const txBuilder = factory.from(txHex);
const txBuilder = factory.from(txHex, { allowedStakingContracts: params.allowedStakingContracts });

if (params.publicKeys !== undefined) {
txBuilder.fromPubKey(params.publicKeys);
Expand Down
7 changes: 7 additions & 0 deletions modules/sdk-coin-stx/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,20 @@ export interface ExplainTransactionOptions {
};
publicKeys?: string[];
feeInfo: TransactionFee;
/**
* Non-canonical staking contracts to accept while re-parsing (e.g. a dummy pox-5), passed
* explicitly so no shared/instance state is involved. Empty/omitted ⇒ canonical-only.
*/
allowedStakingContracts?: ReadonlyArray<{ name: string; address: string }>;
}

export interface StxSignTransactionOptions extends SignTransactionOptions {
txPrebuild: TransactionPrebuild;
prv: string | string[];
pubKeys?: string[];
numberSignature?: number;
/** @see ExplainTransactionOptions.allowedStakingContracts */
allowedStakingContracts?: ReadonlyArray<{ name: string; address: string }>;
}

export interface TransactionPrebuild extends BaseTransactionPrebuild {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import assert from 'assert';
import should from 'should';
import BigNum from 'bn.js';
import { STACKS_TESTNET, STACKS_MAINNET } from '@stacks/network';
import { InvalidParameterValueError, TransactionType } from '@bitgo/sdk-core';
import { InvalidParameterValueError, ParseTransactionError, TransactionType } from '@bitgo/sdk-core';
import { bufferCV, noneCV, someCV, standardPrincipalCV, tupleCV, uintCV, intCV } from '@stacks/transactions';
import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test';
import { BitGoAPI } from '@bitgo/sdk-api';
Expand Down Expand Up @@ -168,6 +168,126 @@ describe('Stacks: Contract Builder', function () {
txJson.payload.functionArgs.length.should.equal(5);
});

it('an unsigned caller-allowlisted non-canonical pox-5 (dummy) contract call transaction', async () => {
// The caller supplies the staking contract it is configured for — no SDK change needed.
const builder = factoryProd.getContractBuilder();
builder.allowedStakingContracts([
{ name: 'pox-5-dummy-v1', address: 'SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY' },
]);
builder.fee({ fee: '180' });
builder.nonce(0);
builder.contractAddress('SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY');
builder.contractName('pox-5-dummy-v1');
builder.functionName('register-for-bond');
builder.functionArgs([
{ type: 'uint128', val: '210' },
{ type: 'principal', val: 'SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY.signer-manager-stackslabs-dummy-v1' },
{ 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-dummy-v1');
should.deepEqual(txJson.payload.contractAddress, 'SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY');
should.deepEqual(txJson.payload.functionName, 'register-for-bond');
});

it('rehydrates an allowlisted non-canonical contract call (sign / explain / fee-rebuild path)', async () => {
// `signTransaction` / `explainTransaction` / the fee re-build all go through `factory.from(hex)`,
// which re-creates the builder — re-parsing must not reject the allowlisted contract.
const allowlist = [{ name: 'pox-5-dummy-v1', address: 'SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY' }];
const builder = factoryProd.getContractBuilder();
builder.allowedStakingContracts(allowlist);
builder.fee({ fee: '180' });
builder.nonce(0);
builder.contractAddress('SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY');
builder.contractName('pox-5-dummy-v1');
builder.functionName('register-for-bond');
builder.functionArgs([
{ type: 'uint128', val: '210' },
{ type: 'principal', val: 'SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY.signer-manager-stackslabs-dummy-v1' },
{ type: 'uint128', val: '1005000' },
{ type: 'optional' },
{ type: 'optional' },
]);
builder.fromPubKey(testData.TX_SENDER.pub);
builder.numberSignatures(1);
const tx = await builder.build();

const reparsed = factoryProd.from(tx.toBroadcastFormat(), { allowedStakingContracts: allowlist });
Comment thread
Doddanna17 marked this conversation as resolved.
reparsed.fromPubKey(testData.TX_SENDER.pub);
reparsed.numberSignatures(1);
const payload = (await reparsed.build()).toJson().payload;
should.deepEqual(payload.contractAddress, 'SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY');
should.deepEqual(payload.contractName, 'pox-5-dummy-v1');

// Without the allowlist the rebuild is rejected — canonical validation is preserved (no relaxation).
(() => factoryProd.from(tx.toBroadcastFormat())).should.throw(ParseTransactionError);
});

it('rejects an allowlisted name followed by a canonical address (order-independent pair check)', function () {
const builder = factoryProd.getContractBuilder();
builder.allowedStakingContracts([
{ name: 'pox-5-dummy-v1', address: 'SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY' },
]);
builder.contractName('pox-5-dummy-v1'); // allowlisted name first
(() => builder.contractAddress('SP000000000000000000002Q6VF78')).should.throw(/not an allowlisted pair/);
});

it('rejects an allowlisted address followed by a canonical name (order-independent pair check)', function () {
const builder = factoryProd.getContractBuilder();
builder.allowedStakingContracts([
{ name: 'pox-5-dummy-v1', address: 'SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY' },
]);
builder.contractAddress('SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY'); // allowlisted address first
(() => builder.contractName('pox-5')).should.throw(/not an allowlisted pair/);
});

it('rejects duplicate allowlisted contract names', function () {
const builder = factoryProd.getContractBuilder();
(() =>
builder.allowedStakingContracts([
{ name: 'pox-5-dummy-v1', address: 'SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY' },
{ name: 'pox-5-dummy-v1', address: 'SP000000000000000000002Q6VF78' },
])).should.throw(/Duplicate staking contract name/);
});

it('rejects an allowlisted name paired with a different address', function () {
Comment thread
Doddanna17 marked this conversation as resolved.
const builder = factoryProd.getContractBuilder();
builder.allowedStakingContracts([
{ name: 'pox-5-dummy-v1', address: 'SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY' },
]);
builder.contractAddress('SP000000000000000000002Q6VF78'); // canonical address, wrong pair
(() => builder.contractName('pox-5-dummy-v1')).should.throw(/not an allowlisted pair/);
});

it('rejects a function that is not in the pox-5 set for an allowlisted contract', function () {
const builder = factoryProd.getContractBuilder();
builder.allowedStakingContracts([
{ name: 'pox-5-dummy-v1', address: 'SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY' },
]);
builder.contractAddress('SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY');
builder.contractName('pox-5-dummy-v1');
// `stack-stx` is a pox-4 function, not part of the pox-5 ABI the dummy mirrors.
(() => builder.functionName('stack-stx')).should.throw(/is not supported contract function name/);
});

it('rejects a contract name that is not allowlisted', function () {
const builder = factory.getContractBuilder();
(() => builder.contractName('some-other-contract')).should.throw(/Only pox-4, pox-5, and send-many-memo/);
});

it('rejects a contract address that is not allowlisted', function () {
const builder = factory.getContractBuilder();
(() => builder.contractAddress('SP3JVSE4ZB5GMDJP5XBPX7ZB1BG3QDFW7RCEQJDMY')).should.throw(
/Invalid contract address/
);
});

it('a signed contract call with args', async () => {
const builder = initTxBuilder();
builder.functionArgs([
Expand Down
Loading