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
4 changes: 2 additions & 2 deletions modules/sdk-coin-stx/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,8 @@
"@bitgo/secp256k1": "^1.11.1",
"@bitgo/statics": "^59.23.1",
"@noble/curves": "1.8.1",
"@stacks/network": "^4.3.0",
"@stacks/transactions": "2.0.1",
"@stacks/network": "^7.6.0",
"@stacks/transactions": "7.6.0",
"bech32": "^2.0.0",
"bignumber.js": "^9.0.0",
"bn.js": "^5.2.1",
Expand Down
23 changes: 15 additions & 8 deletions modules/sdk-coin-stx/src/lib/abstractContractBuilder.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import { BaseCoin as CoinConfig } from '@bitgo/statics';
import BigNum from 'bn.js';
import {
addressToString,
ClarityValue,
ContractCallOptions,
ContractCallPayload,
makeUnsignedContractCall,
PayloadType,
PostCondition,
PostConditionMode,
PostConditionWire,
UnsignedContractCallOptions,
UnsignedMultiSigContractCallOptions,
} from '@stacks/transactions';
Expand All @@ -25,7 +28,7 @@ export abstract class AbstractContractBuilder extends TransactionBuilder {
protected _contractName: string;
protected _functionName: string;
protected _postConditionMode?: PostConditionMode;
protected _postConditions?: PostCondition[];
protected _postConditions?: (PostCondition | PostConditionWire | string)[];
protected _functionArgs: ClarityValue[];

constructor(_coinConfig: Readonly<CoinConfig>) {
Expand All @@ -38,16 +41,20 @@ export abstract class AbstractContractBuilder extends TransactionBuilder {
throw new InvalidTransactionError('payload must not be undefined');
}
if (txData.payload.payloadType === PayloadType.ContractCall) {
this._contractAddress = txData.payload.contractAddress;
this._contractName = txData.payload.contractName;
this._functionName = txData.payload.functionName;
this._functionArgs = txData.payload.functionArgs;
// Read the contract call fields from the deserialized payload. `toJson()` stringifies the
// clarity args (plain `{ type, data | list }` objects) for API consumers, and those are not
// valid ClarityValues under v7 — use the raw payload values so a rebuild stays byte-identical.
const payload = tx.stxTransaction.payload as ContractCallPayload;
this._contractAddress = addressToString(payload.contractAddress);
this._contractName = payload.contractName.content;
this._functionName = payload.functionName.content;
this._functionArgs = payload.functionArgs;
// Restore post-conditions from the deserialized tx — `toJson()` does not carry them, so
// without this a re-build (e.g. the fee re-build via `factory.from(hex)`) would drop them and
// a `Deny`-mode contract call would abort on-chain (SIP-005 post-condition check).
if (tx.stxTransaction) {
this._postConditionMode = tx.stxTransaction.postConditionMode;
this._postConditions = tx.stxTransaction.postConditions.values as PostCondition[];
this._postConditions = tx.stxTransaction.postConditions.values;
}
super.initBuilder(tx);
} else {
Expand Down Expand Up @@ -93,9 +100,9 @@ export abstract class AbstractContractBuilder extends TransactionBuilder {
contractName: this._contractName,
functionName: this._functionName,
functionArgs: this._functionArgs,
postConditionMode: this._postConditionMode,
// v7 requires an explicit post-condition mode; v2 defaulted an unset mode to `Deny`.
postConditionMode: this._postConditionMode ?? PostConditionMode.Deny,
postConditions: this._postConditions,
anchorMode: this._anchorMode,
network: this._network,
fee: new BigNum(this._fee.fee).toString(),
nonce: new BigNum(this._nonce).toString(),
Expand Down
2 changes: 0 additions & 2 deletions modules/sdk-coin-stx/src/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,4 @@ export const VALID_CONTRACT_FUNCTION_NAMES = [

export const DEFAULT_SEED_SIZE_BYTES = 64;

// https://github.com/stacksgov/sips/blob/main/sips/sip-005/sip-005-blocks-and-transactions.md#transaction-encoding
export const ANCHOR_MODE = 3;
export const DEFAULT_MULTISIG_SIG_NUMBER = 2;
33 changes: 30 additions & 3 deletions modules/sdk-coin-stx/src/lib/contractBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@ import { CONTRACT_NAME_SENDMANY, VALID_STAKING_CONTRACT_NAMES } from './constant
import { AbstractContractBuilder } from './abstractContractBuilder';

export class ContractBuilder extends AbstractContractBuilder {
private static readonly CLARITY_TYPE_VALUES = new Set<string>(Object.values(ClarityType));
/** Clarity values that carry no `value` property in v7. */
private static readonly VALUELESS_CLARITY_TYPES = new Set<string>([
ClarityType.OptionalNone,
ClarityType.BoolTrue,
ClarityType.BoolFalse,
]);

constructor(_coinConfig: Readonly<CoinConfig>) {
super(_coinConfig);
}
Expand Down Expand Up @@ -92,16 +100,35 @@ export class ContractBuilder extends AbstractContractBuilder {

functionArgs(args: ClarityValueJson[] | ClarityValue[]): this {
this._functionArgs = args.map((arg) => {
if (!ClarityType[arg.type]) {
return this.parseCv(arg);
} else {
if (this.isClarityValue(arg)) {
// got direct clarity value after deserialization in fromImplementation
return arg;
}
return this.parseCv(arg);
});
return this;
}

/**
* Positive shape check: a fully-formed `ClarityValue` vs a JSON `ClarityValueJson` descriptor.
*
* In `@stacks/transactions` v7 the `ClarityType` values are lower-case strings (`'uint'`,
* `'contract'`, `'address'`, ...). A raw clarity value carries a `type` that is one of those values
* AND a `value` property (except the valueless `none` / `true` / `false`), and never a `val` field.
* JSON descriptors use `type` names (e.g. `optional`, `list`, `response`, `tuple`, `buffer`,
* `contractPrincipal`) and always carry `val` — a malformed descriptor like `{ type: 'list' }`
* therefore falls through to `parseCv` so its validation errors still surface.
*/
private isClarityValue(arg: ClarityValueJson | ClarityValue): arg is ClarityValue {
if (typeof arg.type !== 'string' || !ContractBuilder.CLARITY_TYPE_VALUES.has(arg.type)) {
return false;
}
if ('val' in arg) {
return false;
}
return 'value' in arg || ContractBuilder.VALUELESS_CLARITY_TYPES.has(arg.type);
}

private parseCv(arg: ClarityValueJson): ClarityValue {
switch (arg.type) {
case 'optional':
Expand Down
25 changes: 14 additions & 11 deletions modules/sdk-coin-stx/src/lib/fungibleTokenTransferBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,7 @@ import {
AddressVersion,
ClarityValue,
ContractCallPayload,
FungibleConditionCode,
makeStandardFungiblePostCondition,
Pc,
PostCondition,
PostConditionMode,
} from '@stacks/transactions';
Expand Down Expand Up @@ -141,25 +140,29 @@ export class FungibleTokenTransferBuilder extends AbstractContractBuilder {
private tokenTransferParamsToPostCondition(tokenTransferParams: TokenTransferParams): PostCondition[] {
if (!this._tokenName) {
const contractTokenName = findContractTokenNameUsingContract(this._contractAddress, this._contractName);
if (contractTokenName) {
this.tokenName(contractTokenName);
if (!contractTokenName) {
// Fail the build rather than serialising an `undefined` asset name: such a post-condition can
// never match, so a `Deny`-mode tx would only abort on-chain and burn the fee.
throw new InvalidParameterValueError(
`unknown fungible token contract: ${this._contractAddress}.${this._contractName}`
);
}
this.tokenName(contractTokenName);
}
const amount: BigNum = new BigNum(tokenTransferParams.amount);
return [
makeStandardFungiblePostCondition(
Pc.principal(
getSTXAddressFromPubKeys(
this._fromPubKeys,
this._coinConfig.network.type === NetworkType.MAINNET
? AddressVersion.MainnetMultiSig
: AddressVersion.TestnetMultiSig,
this._fromPubKeys.length > 1 ? AddressHashMode.SerializeP2SH : AddressHashMode.SerializeP2PKH,
this._fromPubKeys.length > 1 ? AddressHashMode.P2SH : AddressHashMode.P2PKH,
this._numberSignatures
).address,
FungibleConditionCode.Equal,
amount.toString(),
`${this._contractAddress}.${this._contractName}::${this._tokenName}`
),
).address
)
.willSendEq(amount.toString())
.ft(`${this._contractAddress}.${this._contractName}`, this._tokenName),
];
}
}
1 change: 1 addition & 0 deletions modules/sdk-coin-stx/src/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ export * from './sbtcWithdrawBuilder';
export * from './btcAddressUtils';
export * from './pox5Builder';
export * from './iface';
export * from './postConditions';
export * as Utils from './utils';
8 changes: 6 additions & 2 deletions modules/sdk-coin-stx/src/lib/keyPair.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { randomBytes } from 'crypto';
import { bip32, ECPair } from '@bitgo/secp256k1';
import { getAddressFromPublicKey, TransactionVersion } from '@stacks/transactions';
import { getAddressFromPublicKey } from '@stacks/transactions';
import { TransactionVersion } from '@stacks/network';
import {
DefaultKeys,
isPrivateKey,
Expand Down Expand Up @@ -123,6 +124,9 @@ export class KeyPair extends Secp256k1ExtendedKeyPair {
* @returns {string} The public address
*/
getSTXAddress(compressed = false, network: TransactionVersion = TransactionVersion.Mainnet): string {
return getAddressFromPublicKey(this.getKeys(compressed).pub, network);
// v7 `getAddressFromPublicKey` takes a network name; `TransactionVersion` only has Mainnet/Testnet,
// so anything other than Testnet maps to mainnet (there is no third value).
const networkName = network === TransactionVersion.Testnet ? 'testnet' : 'mainnet';
return getAddressFromPublicKey(this.getKeys(compressed).pub, networkName);
}
}
7 changes: 4 additions & 3 deletions modules/sdk-coin-stx/src/lib/network.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { BaseCoin as CoinConfig, NetworkType, StacksNetwork as StaticsStacksNetwork } from '@bitgo/statics';
import { StacksMainnet, StacksNetwork, StacksTestnet } from '@stacks/network';
import { STACKS_MAINNET, STACKS_TESTNET, StacksNetwork } from '@stacks/network';

/**
* Build the `@stacks/network` object a transaction is serialized against.
Expand All @@ -15,13 +15,14 @@ import { StacksMainnet, StacksNetwork, StacksTestnet } from '@stacks/network';
*/
export function toStacksNetwork(coinConfig: Readonly<CoinConfig>): StacksNetwork {
const staticsNetwork = coinConfig.network as StaticsStacksNetwork;
const network = staticsNetwork.type === NetworkType.MAINNET ? new StacksMainnet() : new StacksTestnet();
const stock = staticsNetwork.type === NetworkType.MAINNET ? STACKS_MAINNET : STACKS_TESTNET;
const network: StacksNetwork = { ...stock };

if (staticsNetwork.chainId !== undefined) {
network.chainId = staticsNetwork.chainId;
}
if (staticsNetwork.transactionVersion !== undefined) {
network.version = staticsNetwork.transactionVersion;
network.transactionVersion = staticsNetwork.transactionVersion;
}

return network;
Expand Down
80 changes: 80 additions & 0 deletions modules/sdk-coin-stx/src/lib/postConditions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { FungibleConditionCode, Pc, StakingPostCondition } from '@stacks/transactions';

/**
* Comparator accepted by {@link makeStakingPostCondition}: either the `FungibleConditionCode` enum
* from `@stacks/transactions` (`Equal`, `Greater`, `GreaterEqual`, `Less`, `LessEqual`) or the string
* form used by the v7 wire format (`'eq' | 'gt' | 'gte' | 'lt' | 'lte'`).
*/
export type PostConditionCode = FungibleConditionCode | 'eq' | 'gt' | 'gte' | 'lt' | 'lte';

/** Amount accepted by {@link makeStakingPostCondition} (micro-STX). */
export type PostConditionAmount = string | number | bigint;

type Comparator = 'eq' | 'gt' | 'gte' | 'lt' | 'lte';

function toComparator(code: PostConditionCode): Comparator {
if (typeof code === 'string') {
return code;
}
switch (code) {
case FungibleConditionCode.Equal:
return 'eq';
case FungibleConditionCode.Greater:
return 'gt';
case FungibleConditionCode.GreaterEqual:
return 'gte';
case FungibleConditionCode.Less:
return 'lt';
case FungibleConditionCode.LessEqual:
return 'lte';
default:
throw new Error(`Unsupported fungible condition code: ${code}`);
}
}

function applyComparator(
builder: ReturnType<typeof Pc.principal>,
code: PostConditionCode,
amount: PostConditionAmount
): ReturnType<ReturnType<typeof Pc.principal>['willSendEq']> {
switch (toComparator(code)) {
case 'eq':
return builder.willSendEq(amount);
case 'gt':
return builder.willSendGt(amount);
case 'gte':
return builder.willSendGte(amount);
case 'lt':
return builder.willSendLt(amount);
case 'lte':
return builder.willSendLte(amount);
}
}

/**
* Build a SIP-044 **staking** post-condition (`PostConditionType.Staking = 3`).
*
* This is the post-condition required by PoX-5 `register-for-bond` (and any call that locks the
* staker's STX): the staked STX is tracked under the staking condition type, so an STX post-condition
* does **not** cover it and a `Deny`-mode tx with no/mismatched post-condition aborts on-chain with
* `… STX was staked by … but not checked` (SI-1936).
*
* Only `@stacks/transactions` v7 can construct it — the driver for the v7 migration (SI-2024).
*
* @param address the staker principal whose locked STX is asserted
* @param code the comparator (`'eq' | 'gt' | 'gte' | 'lt' | 'lte'` or `FungibleConditionCode`)
* @param amount the locked amount in micro-STX
*
* @example
* ```ts
* builder.postConditionMode(PostConditionMode.Deny);
* builder.postConditions([makeStakingPostCondition(staker, 'eq', amountUstx)]);
* ```
*/
export function makeStakingPostCondition(
address: string,
code: PostConditionCode,
amount: PostConditionAmount
): StakingPostCondition {
return applyComparator(Pc.principal(address), code, amount).ustxToLock();
}
31 changes: 14 additions & 17 deletions modules/sdk-coin-stx/src/lib/sbtcWithdrawBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,8 @@ import {
AddressVersion,
bufferCV,
ClarityType,
createAssetInfo,
FungibleConditionCode,
makeStandardFungiblePostCondition,
ContractCallPayload,
Pc,
PostCondition,
PostConditionMode,
tupleCV,
Expand All @@ -19,7 +18,6 @@ import { Transaction } from './transaction';
import { getSTXAddressFromPubKeys, isValidAmount } from './utils';
import { SbtcWithdrawParams } from './iface';
import { CONTRACT_NAME_SBTC_WITHDRAWAL, FUNCTION_NAME_INITIATE_WITHDRAWAL } from './constants';
import { ContractCallPayload } from '@stacks/transactions/dist/payload';
import { AbstractContractBuilder } from './abstractContractBuilder';
import { decodeBtcAddress, isValidBtcAddress } from './btcAddressUtils';

Expand Down Expand Up @@ -85,8 +83,8 @@ export class SbtcWithdrawBuilder extends AbstractContractBuilder {
if (args[1].type !== ClarityType.Tuple) {
throw new BuildTransactionError('Expected tuple for recipient argument');
}
const versionBuf = args[1].data['version'];
const hashbytesBuf = args[1].data['hashbytes'];
const versionBuf = args[1].value['version'];
const hashbytesBuf = args[1].value['hashbytes'];
if (versionBuf?.type !== ClarityType.Buffer || hashbytesBuf?.type !== ClarityType.Buffer) {
throw new BuildTransactionError('Expected buffer fields in recipient tuple');
}
Expand Down Expand Up @@ -121,16 +119,16 @@ export class SbtcWithdrawBuilder extends AbstractContractBuilder {
if (recipientTuple?.type !== ClarityType.Tuple) {
return undefined;
}
const versionBuf = recipientTuple.data['version'];
const hashbytesBuf = recipientTuple.data['hashbytes'];
const versionBuf = recipientTuple.value['version'];
const hashbytesBuf = recipientTuple.value['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),
recipientVersion: Buffer.from(versionBuf.value, 'hex')[0],
recipientHashBytes: Buffer.from(hashbytesBuf.value, 'hex'),
};
}

Expand Down Expand Up @@ -174,19 +172,18 @@ export class SbtcWithdrawBuilder extends AbstractContractBuilder {
const sbtcContractAddress = network.sbtcWithdrawalContractAddress;

return [
makeStandardFungiblePostCondition(
Pc.principal(
getSTXAddressFromPubKeys(
this._fromPubKeys,
this._coinConfig.network.type === NetworkType.MAINNET
? AddressVersion.MainnetMultiSig
: AddressVersion.TestnetMultiSig,
this._fromPubKeys.length > 1 ? AddressHashMode.SerializeP2SH : AddressHashMode.SerializeP2PKH,
this._fromPubKeys.length > 1 ? AddressHashMode.P2SH : AddressHashMode.P2PKH,
this._numberSignatures
).address,
FungibleConditionCode.Equal,
amount.toString(),
createAssetInfo(sbtcContractAddress, SBTC_TOKEN_CONTRACT_NAME, SBTC_TOKEN_ASSET_NAME)
),
).address
)
.willSendEq(amount.toString())
.ft(`${sbtcContractAddress}.${SBTC_TOKEN_CONTRACT_NAME}`, SBTC_TOKEN_ASSET_NAME),
];
}
}
Loading
Loading