feat: support multisig address

This commit is contained in:
stxer
2025-09-13 04:52:49 -05:00
parent 51994eab79
commit 4f61c4c4c8
12 changed files with 2664 additions and 2370 deletions

28
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,28 @@
{
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true,
"typescript.enablePromptUseWorkspaceTsdk": true,
"typescript.preferences.importModuleSpecifier": "shortest",
"typescript.tsdk": "node_modules/typescript/lib",
"editor.codeActionsOnSave": {
"source.fixAll.biome": "explicit"
},
"[json]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[jsonc]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[javascript]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[javascriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[typescriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
}
}

63
biome.json Normal file
View File

@@ -0,0 +1,63 @@
{
"$schema": "https://biomejs.dev/schemas/2.2.4/schema.json",
"assist": {
"actions": {
"source": {
"organizeImports": "on"
}
}
},
"files": {
"includes": [
"**",
"!**/coverage",
"!**/dist",
"!**/node_modules",
"!**/cache"
]
},
"formatter": {
"enabled": true,
"formatWithErrors": false,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 80
},
"javascript": {
"formatter": {
"quoteStyle": "single",
"trailingCommas": "all",
"semicolons": "always"
}
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"complexity": {
"noBannedTypes": "off"
},
"correctness": {
"noUnusedVariables": "error",
"noUnusedImports": "error"
},
"style": {
"noParameterAssign": "error",
"useAsConstAssertion": "error",
"useDefaultParameterLast": "error",
"useEnumInitializers": "error",
"useSelfClosingElements": "error",
"useSingleVarDeclarator": "error",
"noUnusedTemplateLiteral": "error",
"useNumberNamespace": "error",
"noInferrableTypes": "error",
"noUselessElse": "error"
}
}
},
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": false
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "stxer",
"version": "0.4.2",
"version": "0.4.3",
"license": "MIT",
"author": "Kyle Fang",
"repository": {
@@ -17,7 +17,7 @@
"scripts": {
"analyze": "size-limit --why",
"build": "dts build",
"lint": "dts lint",
"lint": "dts lint && biome check .",
"prepare": "dts build",
"size": "size-limit",
"start": "dts watch",
@@ -27,7 +27,7 @@
},
"husky": {
"hooks": {
"pre-commit": "dts lint"
"pre-commit": "dts lint && biome check ."
}
},
"prettier": {
@@ -53,22 +53,23 @@
}
],
"devDependencies": {
"@size-limit/preset-small-lib": "^11.1.5",
"@tsconfig/recommended": "^1.0.7",
"@types/node": "^22.13.5",
"@biomejs/biome": "^2.2.4",
"@size-limit/preset-small-lib": "^11.2.0",
"@tsconfig/recommended": "^1.0.10",
"@types/node": "^24.3.3",
"dts-cli": "^2.0.5",
"husky": "^9.1.6",
"size-limit": "^11.1.5",
"tslib": "^2.7.0",
"tsx": "^4.19.2",
"typescript": "^5.6.2"
"husky": "^9.1.7",
"size-limit": "^11.2.0",
"tslib": "^2.8.1",
"tsx": "^4.20.5",
"typescript": "^5.9.2"
},
"dependencies": {
"@stacks/network": "^7.0.0",
"@stacks/network": "^7.2.0",
"@stacks/stacks-blockchain-api-types": "^7.14.1",
"@stacks/transactions": "^7.0.0",
"@stacks/transactions": "^7.2.0",
"c32check": "^2.0.0",
"clarity-abi": "^0.1.0",
"ts-clarity": "^0.1.0-pre.2"
"ts-clarity": "^0.1.1"
}
}

4617
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

2
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,2 @@
onlyBuiltDependencies:
- esbuild

View File

@@ -59,7 +59,7 @@ function convertResults(
export async function batchRead(
reads: BatchReads,
options: BatchApiOptions = {}
options: BatchApiOptions = {},
): Promise<BatchReadsResult> {
const ibh =
reads.index_block_hash == null
@@ -77,7 +77,10 @@ export async function batchRead(
if (reads.variables != null) {
for (const variable of reads.variables) {
payload.vars.push([serializeCV(variable.contract), variable.variableName]);
payload.vars.push([
serializeCV(variable.contract),
variable.variableName,
]);
}
}
@@ -96,7 +99,7 @@ export async function batchRead(
payload.readonly.push([
serializeCV(ro.contract),
ro.functionName,
...ro.functionArgs.map(v => serializeCV(v)),
...ro.functionArgs.map((v) => serializeCV(v)),
]);
}
}

View File

@@ -7,8 +7,8 @@
import {
type ClarityValue,
type OptionalCV,
contractPrincipalCV,
type OptionalCV,
} from '@stacks/transactions';
import { type BatchReads, batchRead } from './BatchAPI';

View File

@@ -10,15 +10,15 @@ import type {
TContractPrincipal,
TPrincipal,
} from 'clarity-abi';
import { decodeAbi, encodeAbi } from 'ts-clarity';
import type {
InferReadonlyCallParameterType,
InferReadonlyCallResultType,
InferMapValueType,
InferReadMapParameterType,
InferReadonlyCallParameterType,
InferReadonlyCallResultType,
InferReadVariableParameterType,
InferVariableType,
} from 'ts-clarity';
import { decodeAbi, encodeAbi } from 'ts-clarity';
import { BatchProcessor } from './BatchProcessor';
// Shared processor instance with default settings
@@ -65,7 +65,9 @@ export async function callReadonly<
(def) => def.name === params.functionName,
);
if (!functionDef) {
throw new Error(`failed to find function definition for ${params.functionName}`);
throw new Error(
`failed to find function definition for ${params.functionName}`,
);
}
const argsKV = (params as unknown as { args: Record<string, unknown> }).args;
@@ -87,7 +89,9 @@ export async function callReadonly<
resolve: (result: ClarityValue | OptionalCV) => {
try {
const decoded = decodeAbi(functionDef.outputs.type, result);
resolve(decoded as InferReadonlyCallResultType<Functions, FunctionName>);
resolve(
decoded as InferReadonlyCallResultType<Functions, FunctionName>,
);
} catch (error) {
reject(error);
}
@@ -98,7 +102,9 @@ export async function callReadonly<
}
export async function readMap<
Maps extends readonly ClarityAbiMap[] | readonly unknown[] = readonly ClarityAbiMap[],
Maps extends
| readonly ClarityAbiMap[]
| readonly unknown[] = readonly ClarityAbiMap[],
MapName extends string = string,
>(
params: InferReadMapParameterType<Maps, MapName> & ReadMapRuntimeParameters,
@@ -134,7 +140,10 @@ export async function readMap<
if (result.type !== ClarityType.OptionalSome) {
throw new Error(`unexpected map value: ${result}`);
}
const someCV = result as { type: ClarityType.OptionalSome; value: ClarityValue };
const someCV = result as {
type: ClarityType.OptionalSome;
value: ClarityValue;
};
const decoded = decodeAbi(mapDef.value, someCV.value);
resolve(decoded as InferMapValueType<Maps, MapName>);
} catch (error) {
@@ -147,7 +156,9 @@ export async function readMap<
}
export async function readVariable<
Variables extends readonly ClarityAbiVariable[] | readonly unknown[] = readonly ClarityAbiVariable[],
Variables extends
| readonly ClarityAbiVariable[]
| readonly unknown[] = readonly ClarityAbiVariable[],
VariableName extends string = string,
>(
params: InferReadVariableParameterType<Variables, VariableName> &
@@ -160,7 +171,9 @@ export async function readVariable<
(def) => def.name === params.variableName,
);
if (!varDef) {
throw new Error(`failed to find variable definition for ${params.variableName}`);
throw new Error(
`failed to find variable definition for ${params.variableName}`,
);
}
return new Promise((resolve, reject) => {

View File

@@ -1,3 +1,3 @@
export * from './BatchAPI';
export * from './simulation';
export * from './clarity-api';
export * from './simulation';

View File

@@ -1,9 +1,11 @@
import { uintCV } from '@stacks/transactions';
import { SimulationBuilder } from '..';
const test = () => SimulationBuilder.new()
.withSender('SP212Y5JKN59YP3GYG07K3S8W5SSGE4KH6B5STXER')
.inlineSimulation('1ab04a8d13d72a301b77b6af9d4f612b')
const sender = 'SP212Y5JKN59YP3GYG07K3S8W5SSGE4KH6B5STXER';
const test = () =>
SimulationBuilder.new()
.withSender(sender)
.addContractDeploy({
contract_name: 'test-simulation',
source_code: `
@@ -24,23 +26,17 @@ const test = () => SimulationBuilder.new()
(ok (var-get counter)))
`,
})
.addEvalCode(
'SP212Y5JKN59YP3GYG07K3S8W5SSGE4KH6B5STXER.test-simulation',
'(get-counter)'
)
.addEvalCode(`${sender}.test-simulation`, '(get-counter)')
.addContractCall({
contract_id: 'SP212Y5JKN59YP3GYG07K3S8W5SSGE4KH6B5STXER.test-simulation',
contract_id: `${sender}.test-simulation`,
function_name: 'increment',
function_args: [uintCV(10)],
})
.addEvalCode(
'SP212Y5JKN59YP3GYG07K3S8W5SSGE4KH6B5STXER.test-simulation',
'(get-counter)'
)
.run()
.addEvalCode(`${sender}.test-simulation`, '(get-counter)')
.run();
if (require.main === module) {
; (async () => {
await test()
(async () => {
await test();
})().catch(console.error);
}

View File

@@ -4,11 +4,11 @@ import {
tupleCV,
uintCV,
} from '@stacks/transactions';
import { SIP010TraitABI } from 'clarity-abi/abis'
import { SIP010TraitABI } from 'clarity-abi/abis';
import { unwrapResponse } from 'ts-clarity';
import { batchRead } from '../BatchAPI';
import { BatchProcessor } from '../BatchProcessor';
import { callReadonly, readMap, readVariable } from '../clarity-api';
import { unwrapResponse } from 'ts-clarity';
async function batchReadsExample() {
const rs = await batchRead({
@@ -18,21 +18,21 @@ async function batchReadsExample() {
{
contract: contractPrincipalCV(
'SP1Z92MPDQEWZXW36VX71Q25HKF5K2EPCJ304F275',
'liquidity-token-v5kbe3oqvac'
'liquidity-token-v5kbe3oqvac',
),
variableName: 'balance-x',
},
{
contract: contractPrincipalCV(
'SP1Z92MPDQEWZXW36VX71Q25HKF5K2EPCJ304F275',
'liquidity-token-v5kbe3oqvac'
'liquidity-token-v5kbe3oqvac',
),
variableName: 'balance-y',
},
{
contract: contractPrincipalCV(
'SP1Z92MPDQEWZXW36VX71Q25HKF5K2EPCJ304F275',
'liquidity-token-v5kbe3oqvac'
'liquidity-token-v5kbe3oqvac',
),
variableName: 'something-not-exists',
},
@@ -41,15 +41,15 @@ async function batchReadsExample() {
{
contract: contractPrincipalCV(
'SP102V8P0F7JX67ARQ77WEA3D3CFB5XW39REDT0AM',
'amm-registry-v2-01'
'amm-registry-v2-01',
),
mapName: 'pools-data-map',
mapKey: tupleCV({
'token-x': principalCV(
'SP102V8P0F7JX67ARQ77WEA3D3CFB5XW39REDT0AM.token-wstx-v2'
'SP102V8P0F7JX67ARQ77WEA3D3CFB5XW39REDT0AM.token-wstx-v2',
),
'token-y': principalCV(
'SP102V8P0F7JX67ARQ77WEA3D3CFB5XW39REDT0AM.token-alex'
'SP102V8P0F7JX67ARQ77WEA3D3CFB5XW39REDT0AM.token-alex',
),
factor: uintCV(1e8),
}),
@@ -57,7 +57,7 @@ async function batchReadsExample() {
{
contract: contractPrincipalCV(
'SP1Y5YSTAHZ88XYK1VPDH24GY0HPX5J4JECTMY4A1',
'univ2-core'
'univ2-core',
),
mapName: 'pools',
mapKey: uintCV(1),
@@ -65,7 +65,7 @@ async function batchReadsExample() {
{
contract: contractPrincipalCV(
'SP1Y5YSTAHZ88XYK1VPDH24GY0HPX5J4JECTMY4A1',
'contract-not-exists'
'contract-not-exists',
),
mapName: 'pools',
mapKey: uintCV(1),
@@ -81,7 +81,6 @@ async function batchQueueProcessorExample() {
batchDelayMs: 1000,
});
const promiseA = processor.read({
mode: 'variable',
contractAddress: 'SP1Z92MPDQEWZXW36VX71Q25HKF5K2EPCJ304F275',
@@ -105,7 +104,7 @@ async function batchSip010Example() {
abi: SIP010TraitABI.functions,
functionName: 'get-total-supply',
contract: 'SP102V8P0F7JX67ARQ77WEA3D3CFB5XW39REDT0AM.token-alex',
}).then(unwrapResponse)
}).then(unwrapResponse);
const balance = callReadonly({
abi: SIP010TraitABI.functions,
functionName: 'get-balance',
@@ -113,20 +112,18 @@ async function batchSip010Example() {
args: {
who: 'SP102V8P0F7JX67ARQ77WEA3D3CFB5XW39REDT0AM.amm-vault-v2-01',
},
}).then(unwrapResponse)
}).then(unwrapResponse);
const paused = readVariable({
abi: [{ name: 'paused', type: 'bool', access: 'variable' },],
abi: [{ name: 'paused', type: 'bool', access: 'variable' }],
variableName: 'paused',
contract: 'SP102V8P0F7JX67ARQ77WEA3D3CFB5XW39REDT0AM.amm-vault-v2-01',
});
const approved = readMap({
abi: [
{ key: 'principal', name: 'approved-tokens', value: 'bool' },
],
abi: [{ key: 'principal', name: 'approved-tokens', value: 'bool' }],
mapName: 'approved-tokens',
key: 'SP102V8P0F7JX67ARQ77WEA3D3CFB5XW39REDT0AM.token-alex',
contract: 'SP102V8P0F7JX67ARQ77WEA3D3CFB5XW39REDT0AM.amm-vault-v2-01',
})
});
const result = await Promise.all([supply, balance, paused, approved]);
console.log(result);
}

View File

@@ -1,27 +1,55 @@
import { type AccountDataResponse, getNodeInfo, richFetch } from 'ts-clarity';
import type { Block } from '@stacks/stacks-blockchain-api-types';
import {
AddressVersion,
STACKS_MAINNET,
STACKS_TESTNET,
type StacksNetworkName,
} from '@stacks/network';
import type { Block } from '@stacks/stacks-blockchain-api-types';
import {
AddressHashMode,
bufferCV,
type ClarityValue,
ClarityVersion,
PostConditionMode,
type StacksTransactionWire,
bufferCV,
contractPrincipalCV,
deserializeTransaction,
type MultiSigSpendingCondition,
makeUnsignedContractCall,
makeUnsignedContractDeploy,
makeUnsignedSTXTokenTransfer,
PostConditionMode,
type StacksTransactionWire,
serializeCVBytes,
stringAsciiCV,
tupleCV,
uintCV,
} from '@stacks/transactions';
import { c32addressDecode } from 'c32check';
import { type AccountDataResponse, getNodeInfo, richFetch } from 'ts-clarity';
function setSender(tx: StacksTransactionWire, sender: string) {
const [addressVersion, signer] = c32addressDecode(sender);
switch (addressVersion) {
case AddressVersion.MainnetSingleSig:
case AddressVersion.TestnetSingleSig:
tx.auth.spendingCondition.hashMode = AddressHashMode.P2PKH;
tx.auth.spendingCondition.signer = signer;
break;
case AddressVersion.MainnetMultiSig:
case AddressVersion.TestnetMultiSig: {
const sc = tx.auth.spendingCondition;
tx.auth.spendingCondition = {
hashMode: AddressHashMode.P2SH,
signer,
fields: [],
signaturesRequired: 0,
nonce: sc.nonce,
fee: sc.fee,
} as MultiSigSpendingCondition;
break;
}
}
return tx;
}
function runTx(tx: StacksTransactionWire) {
// type 0: run transaction
@@ -43,8 +71,8 @@ export function runEval({ contract_id, code }: SimulationEval) {
tupleCV({
contract: contractPrincipalCV(contract_address, contract_name),
code: stringAsciiCV(code),
})
)
}),
),
),
});
}
@@ -53,7 +81,7 @@ export async function runSimulation(
apiEndpoint: string,
block_hash: string,
block_height: number,
txs: (StacksTransactionWire | SimulationEval)[]
txs: (StacksTransactionWire | SimulationEval)[],
) {
// Convert 'sim-v1' to Uint8Array
const header = new TextEncoder().encode('sim-v1');
@@ -73,7 +101,7 @@ export async function runSimulation(
throw new Error('Invalid block hash format');
}
const hashBytes = new Uint8Array(
matches.map((byte) => Number.parseInt(byte, 16))
matches.map((byte) => Number.parseInt(byte, 16)),
);
// Convert transactions to bytes
@@ -129,9 +157,11 @@ export class SimulationBuilder {
this.network = options.network ?? 'mainnet';
const isTestnet = this.network === 'testnet';
this.apiEndpoint = options.apiEndpoint ??
this.apiEndpoint =
options.apiEndpoint ??
(isTestnet ? 'https://testnet-api.stxer.xyz' : 'https://api.stxer.xyz');
this.stacksNodeAPI = options.stacksNodeAPI ??
this.stacksNodeAPI =
options.stacksNodeAPI ??
(isTestnet ? 'https://api.testnet.hiro.so' : 'https://api.hiro.so');
}
@@ -139,7 +169,7 @@ export class SimulationBuilder {
return new SimulationBuilder(options);
}
// biome-ignore lint/style/useNumberNamespace: <explanation>
// biome-ignore lint/style/useNumberNamespace: ignore this
private block = NaN;
private sender = '';
private steps: (
@@ -184,7 +214,7 @@ export class SimulationBuilder {
public inlineSimulation(simulationId: string) {
this.steps.push({
simulationId,
})
});
return this;
}
public addSTXTransfer(params: {
@@ -195,7 +225,7 @@ export class SimulationBuilder {
}) {
if (params.sender == null && this.sender === '') {
throw new Error(
'Please specify a sender with useSender or adding a sender paramenter'
'Please specify a sender with useSender or adding a sender paramenter',
);
}
this.steps.push({
@@ -214,7 +244,7 @@ export class SimulationBuilder {
}) {
if (params.sender == null && this.sender === '') {
throw new Error(
'Please specify a sender with useSender or adding a sender paramenter'
'Please specify a sender with useSender or adding a sender paramenter',
);
}
this.steps.push({
@@ -233,7 +263,7 @@ export class SimulationBuilder {
}) {
if (params.deployer == null && this.sender === '') {
throw new Error(
'Please specify a deployer with useSender or adding a deployer paramenter'
'Please specify a deployer with useSender or adding a deployer paramenter',
);
}
this.steps.push({
@@ -274,7 +304,7 @@ export class SimulationBuilder {
this.block = stacks_tip_height;
}
const info: Block = await richFetch(
`${this.stacksNodeAPI}/extended/v1/block/by_height/${this.block}?unanchored=true`
`${this.stacksNodeAPI}/extended/v1/block/by_height/${this.block}?unanchored=true`,
).then((r) => r.json());
if (
info.height !== this.block ||
@@ -282,7 +312,7 @@ export class SimulationBuilder {
!info.hash.startsWith('0x')
) {
throw new Error(
`failed to get block info for block height ${this.block}`
`failed to get block info for block height ${this.block}`,
);
}
return {
@@ -302,21 +332,22 @@ SP212Y5JKN59YP3GYG07K3S8W5SSGE4KH6B5STXER
Feedbacks and feature requests are welcome.
To get in touch: contact@stxer.xyz
--------------------------------`
--------------------------------`,
);
const block = await this.getBlockInfo();
console.log(
`Using block height ${block.block_height} hash 0x${block.block_hash} to run simulation.`
`Using block height ${block.block_height} hash 0x${block.block_hash} to run simulation.`,
);
const txs: (StacksTransactionWire | SimulationEval)[] = [];
const nonce_by_address = new Map<string, number>();
const nextNonce = async (sender: string) => {
const nonce = nonce_by_address.get(sender);
if (nonce == null) {
const url = `${this.stacksNodeAPI
const url = `${
this.stacksNodeAPI
}/v2/accounts/${sender}?proof=${false}&tip=${block.index_block_hash}`;
const account: AccountDataResponse = await richFetch(url).then((r) =>
r.json()
r.json(),
);
nonce_by_address.set(sender, account.nonce + 1);
return account.nonce;
@@ -336,7 +367,21 @@ To get in touch: contact@stxer.xyz
}
for (const step of this.steps) {
if ('simulationId' in step) {
const previousSimulation: {steps: ({tx: string} | {code: string, contract: string})[]} = await fetch(`https://api.stxer.xyz/simulations/${step.simulationId}/request`).then(x => x.json())
const previousSimulation: {
steps: ({ tx: string } | { code: string; contract: string })[];
} = await fetch(
`https://api.stxer.xyz/simulations/${step.simulationId}/request`,
).then(async (rs) => {
const body = await rs.text();
if (!body.startsWith('{')) {
throw new Error(
`failed to get simulation ${step.simulationId}: ${body}`,
);
}
return JSON.parse(body) as {
steps: ({ tx: string } | { code: string; contract: string })[];
};
});
for (const step of previousSimulation.steps) {
if ('tx' in step) {
txs.push(deserializeTransaction(step.tx));
@@ -361,7 +406,7 @@ To get in touch: contact@stxer.xyz
postConditionMode: PostConditionMode.Allow,
fee: step.fee,
});
tx.auth.spendingCondition.signer = c32addressDecode(step.sender)[1];
setSender(tx, step.sender);
txs.push(tx);
} else if ('sender' in step && 'recipient' in step) {
const nonce = await nextNonce(step.sender);
@@ -373,7 +418,7 @@ To get in touch: contact@stxer.xyz
publicKey: '',
fee: step.fee,
});
tx.auth.spendingCondition.signer = c32addressDecode(step.sender)[1];
setSender(tx, step.sender);
txs.push(tx);
} else if ('deployer' in step) {
const nonce = await nextNonce(step.deployer);
@@ -386,28 +431,29 @@ To get in touch: contact@stxer.xyz
postConditionMode: PostConditionMode.Allow,
fee: step.fee,
});
tx.auth.spendingCondition.signer = c32addressDecode(step.deployer)[1];
setSender(tx, step.deployer);
txs.push(tx);
} else if ('code' in step) {
txs.push(step);
} else {
// biome-ignore lint/style/noUnusedTemplateLiteral: <explanation>
console.log(`Invalid simulation step:`, step);
console.log(`Invalid simulation step: ${step}`);
}
}
const id = await runSimulation(
`${this.apiEndpoint}/simulations`,
block.block_hash,
block.block_height,
txs
txs,
);
console.log(
`Simulation will be available at: https://stxer.xyz/simulations/${this.network}/${id}`
`Simulation will be available at: https://stxer.xyz/simulations/${this.network}/${id}`,
);
return id;
}
public pipe(transform: (builder: SimulationBuilder) => SimulationBuilder): SimulationBuilder {
public pipe(
transform: (builder: SimulationBuilder) => SimulationBuilder,
): SimulationBuilder {
return transform(this);
}
}