mirror of
https://github.com/stxer/stxer-sdk.git
synced 2026-01-12 07:23:57 +08:00
feat: add batch api
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "stxer",
|
||||
"version": "0.2.6",
|
||||
"version": "0.3.0",
|
||||
"license": "MIT",
|
||||
"author": "Kyle Fang",
|
||||
"main": "dist/index.js",
|
||||
@@ -18,7 +18,8 @@
|
||||
"size": "size-limit",
|
||||
"start": "dts watch",
|
||||
"test": "dts test",
|
||||
"sample": "tsx src/sample/counter.ts"
|
||||
"sample:counter": "tsx src/sample/counter.ts",
|
||||
"sample:read": "tsx src/sample/read.ts"
|
||||
},
|
||||
"husky": {
|
||||
"hooks": {
|
||||
|
||||
157
src/batch-api.ts
Normal file
157
src/batch-api.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import {
|
||||
type ClarityValue,
|
||||
type ContractPrincipalCV,
|
||||
deserializeCV,
|
||||
type OptionalCV,
|
||||
serializeCV,
|
||||
} from '@stacks/transactions';
|
||||
|
||||
export interface BatchReads {
|
||||
variables?: {
|
||||
contract: ContractPrincipalCV;
|
||||
variableName: string;
|
||||
}[];
|
||||
maps?: {
|
||||
contract: ContractPrincipalCV;
|
||||
mapName: string;
|
||||
mapKey: ClarityValue;
|
||||
}[];
|
||||
index_block_hash?: string;
|
||||
}
|
||||
|
||||
export interface BatchReadsResult {
|
||||
variables: (ClarityValue | Error)[];
|
||||
maps: (OptionalCV | Error)[];
|
||||
}
|
||||
|
||||
export interface BatchApiOptions {
|
||||
stxerApi?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_STXER_API = 'https://api.stxer.xyz';
|
||||
|
||||
export async function batchRead(
|
||||
reads: BatchReads,
|
||||
options: BatchApiOptions = {}
|
||||
): Promise<BatchReadsResult> {
|
||||
const payload: string[][] = [];
|
||||
if (reads.variables != null) {
|
||||
for (const variable of reads.variables) {
|
||||
payload.push([serializeCV(variable.contract), variable.variableName]);
|
||||
}
|
||||
}
|
||||
if (reads.maps != null) {
|
||||
for (const map of reads.maps) {
|
||||
payload.push([
|
||||
serializeCV(map.contract),
|
||||
map.mapName,
|
||||
serializeCV(map.mapKey),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
const ibh =
|
||||
reads.index_block_hash == null
|
||||
? null
|
||||
: reads.index_block_hash.startsWith('0x')
|
||||
? reads.index_block_hash.substring(2)
|
||||
: reads.index_block_hash;
|
||||
|
||||
const url = `${options.stxerApi ?? DEFAULT_STXER_API}/sidecar/v2/batch-reads${
|
||||
ibh == null ? '' : `?tip=${ibh}`
|
||||
}`;
|
||||
const data = await fetch(url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const text = await data.text();
|
||||
if (!text.includes('Ok') && !text.includes('Err')) {
|
||||
throw new Error(
|
||||
`Requesting batch reads failed: ${text}, url: ${url}, payload: ${JSON.stringify(
|
||||
payload
|
||||
)}`
|
||||
);
|
||||
}
|
||||
const results = JSON.parse(text) as [{ Ok: string } | { Err: string }];
|
||||
const rs: BatchReadsResult = {
|
||||
variables: [],
|
||||
maps: [],
|
||||
};
|
||||
let variablesLength = 0;
|
||||
if (reads.variables != null) {
|
||||
variablesLength = reads.variables.length;
|
||||
for (let i = 0; i < variablesLength; i++) {
|
||||
const result = results[i];
|
||||
if ('Ok' in result) {
|
||||
rs.variables.push(deserializeCV(result.Ok));
|
||||
} else {
|
||||
rs.variables.push(new Error(result.Err));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (reads.maps != null) {
|
||||
for (let i = 0; i < reads.maps.length; i++) {
|
||||
const result = results[i + variablesLength];
|
||||
if ('Ok' in result) {
|
||||
rs.maps.push(deserializeCV(result.Ok) as OptionalCV);
|
||||
} else {
|
||||
rs.maps.push(new Error(result.Err));
|
||||
}
|
||||
}
|
||||
}
|
||||
return rs;
|
||||
}
|
||||
|
||||
export interface BatchReadonlyRequest {
|
||||
readonly: {
|
||||
contract: ContractPrincipalCV;
|
||||
functionName: string;
|
||||
functionArgs: ClarityValue[];
|
||||
}[];
|
||||
index_block_hash?: string;
|
||||
}
|
||||
|
||||
export async function batchReadonly(
|
||||
req: BatchReadonlyRequest,
|
||||
options: BatchApiOptions = {}
|
||||
): Promise<(ClarityValue | Error)[]> {
|
||||
const payload = req.readonly.map((r) => [
|
||||
serializeCV(r.contract),
|
||||
r.functionName,
|
||||
...r.functionArgs.map((arg) => serializeCV(arg)),
|
||||
]);
|
||||
|
||||
const ibh =
|
||||
req.index_block_hash == null
|
||||
? null
|
||||
: req.index_block_hash.startsWith('0x')
|
||||
? req.index_block_hash.substring(2)
|
||||
: req.index_block_hash;
|
||||
|
||||
const url = `${options.stxerApi ?? DEFAULT_STXER_API}/sidecar/v2/batch-readonly${
|
||||
ibh == null ? '' : `?tip=${ibh}`
|
||||
}`;
|
||||
const data = await fetch(url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const text = await data.text();
|
||||
if (!text.includes('Ok') && !text.includes('Err')) {
|
||||
throw new Error(
|
||||
`Requesting batch readonly failed: ${text}, url: ${url}, payload: ${JSON.stringify(
|
||||
payload
|
||||
)}`
|
||||
);
|
||||
}
|
||||
const results = JSON.parse(text) as [{ Ok: string } | { Err: string }];
|
||||
const rs: (ClarityValue | Error)[] = [];
|
||||
for (const result of results) {
|
||||
if ('Ok' in result) {
|
||||
rs.push(deserializeCV(result.Ok));
|
||||
} else {
|
||||
rs.push(new Error(result.Err));
|
||||
}
|
||||
}
|
||||
return rs;
|
||||
}
|
||||
|
||||
390
src/index.ts
390
src/index.ts
@@ -1,388 +1,2 @@
|
||||
import { type AccountDataResponse, getNodeInfo, richFetch } from 'ts-clarity';
|
||||
import type { Block } from '@stacks/stacks-blockchain-api-types';
|
||||
import {
|
||||
networkFrom,
|
||||
STACKS_MAINNET,
|
||||
STACKS_TESTNET,
|
||||
type StacksNetworkName,
|
||||
} from '@stacks/network';
|
||||
import {
|
||||
type ClarityValue,
|
||||
ClarityVersion,
|
||||
PostConditionMode,
|
||||
type StacksTransactionWire,
|
||||
bufferCV,
|
||||
contractPrincipalCV,
|
||||
makeUnsignedContractCall,
|
||||
makeUnsignedContractDeploy,
|
||||
makeUnsignedSTXTokenTransfer,
|
||||
serializeCVBytes,
|
||||
stringAsciiCV,
|
||||
tupleCV,
|
||||
uintCV,
|
||||
} from '@stacks/transactions';
|
||||
import { c32addressDecode } from 'c32check';
|
||||
|
||||
function runTx(tx: StacksTransactionWire) {
|
||||
// type 0: run transaction
|
||||
return tupleCV({ type: uintCV(0), data: bufferCV(tx.serializeBytes()) });
|
||||
}
|
||||
|
||||
export interface SimulationEval {
|
||||
contract_id: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
export function runEval({ contract_id, code }: SimulationEval) {
|
||||
const [contract_address, contract_name] = contract_id.split('.');
|
||||
// type 1: eval arbitrary code inside a contract
|
||||
return tupleCV({
|
||||
type: uintCV(1),
|
||||
data: bufferCV(
|
||||
serializeCVBytes(
|
||||
tupleCV({
|
||||
contract: contractPrincipalCV(contract_address, contract_name),
|
||||
code: stringAsciiCV(code),
|
||||
})
|
||||
)
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export async function runSimulation(
|
||||
apiEndpoint: string,
|
||||
block_hash: string,
|
||||
block_height: number,
|
||||
txs: (StacksTransactionWire | SimulationEval)[]
|
||||
) {
|
||||
// Convert 'sim-v1' to Uint8Array
|
||||
const header = new TextEncoder().encode('sim-v1');
|
||||
// Create 8 bytes for block height
|
||||
const heightBytes = new Uint8Array(8);
|
||||
// Convert block height to bytes
|
||||
const view = new DataView(heightBytes.buffer);
|
||||
view.setBigUint64(0, BigInt(block_height), false); // false for big-endian
|
||||
|
||||
// Convert block hash to bytes
|
||||
const hashHex = block_hash.startsWith('0x')
|
||||
? block_hash.substring(2)
|
||||
: block_hash;
|
||||
// Replace non-null assertion with null check
|
||||
const matches = hashHex.match(/.{1,2}/g);
|
||||
if (!matches) {
|
||||
throw new Error('Invalid block hash format');
|
||||
}
|
||||
const hashBytes = new Uint8Array(
|
||||
matches.map((byte) => Number.parseInt(byte, 16))
|
||||
);
|
||||
|
||||
// Convert transactions to bytes
|
||||
const txBytes = txs
|
||||
.map((t) => ('contract_id' in t && 'code' in t ? runEval(t) : runTx(t)))
|
||||
.map((t) => serializeCVBytes(t));
|
||||
|
||||
// Combine all byte arrays
|
||||
const totalLength =
|
||||
header.length +
|
||||
heightBytes.length +
|
||||
hashBytes.length +
|
||||
txBytes.reduce((acc, curr) => acc + curr.length, 0);
|
||||
const body = new Uint8Array(totalLength);
|
||||
|
||||
let offset = 0;
|
||||
body.set(header, offset);
|
||||
offset += header.length;
|
||||
body.set(heightBytes, offset);
|
||||
offset += heightBytes.length;
|
||||
body.set(hashBytes, offset);
|
||||
offset += hashBytes.length;
|
||||
for (const tx of txBytes) {
|
||||
body.set(tx, offset);
|
||||
offset += tx.length;
|
||||
}
|
||||
|
||||
const rs = await fetch(apiEndpoint, {
|
||||
method: 'POST',
|
||||
body,
|
||||
}).then(async (rs) => {
|
||||
const response = await rs.text();
|
||||
if (!response.startsWith('{')) {
|
||||
throw new Error(`failed to submit simulation: ${response}`);
|
||||
}
|
||||
return JSON.parse(response) as { id: string };
|
||||
});
|
||||
return rs.id;
|
||||
}
|
||||
|
||||
interface SimulationBuilderOptions {
|
||||
apiEndpoint?: string;
|
||||
stacksNodeAPI?: string;
|
||||
network?: StacksNetworkName | string;
|
||||
}
|
||||
|
||||
export class SimulationBuilder {
|
||||
private apiEndpoint: string;
|
||||
private stacksNodeAPI: string;
|
||||
private network: StacksNetworkName | string;
|
||||
|
||||
private constructor(options: SimulationBuilderOptions = {}) {
|
||||
this.apiEndpoint = options.apiEndpoint ?? 'https://api.stxer.xyz';
|
||||
this.stacksNodeAPI = options.stacksNodeAPI ?? 'https://api.hiro.so';
|
||||
this.network = options.network ?? 'mainnet';
|
||||
}
|
||||
|
||||
public static new(options?: SimulationBuilderOptions) {
|
||||
return new SimulationBuilder(options);
|
||||
}
|
||||
|
||||
// biome-ignore lint/style/useNumberNamespace: <explanation>
|
||||
private block = NaN;
|
||||
private sender = '';
|
||||
private steps: (
|
||||
| {
|
||||
// contract call
|
||||
contract_id: string;
|
||||
function_name: string;
|
||||
function_args?: ClarityValue[];
|
||||
sender: string;
|
||||
fee: number;
|
||||
}
|
||||
| {
|
||||
// contract deploy
|
||||
contract_name: string;
|
||||
source_code: string;
|
||||
deployer: string;
|
||||
fee: number;
|
||||
clarity_version: ClarityVersion;
|
||||
}
|
||||
| {
|
||||
// STX transfer
|
||||
recipient: string;
|
||||
amount: number;
|
||||
sender: string;
|
||||
fee: number;
|
||||
}
|
||||
| SimulationEval
|
||||
)[] = [];
|
||||
|
||||
public useBlockHeight(block: number) {
|
||||
this.block = block;
|
||||
return this;
|
||||
}
|
||||
public withSender(address: string) {
|
||||
this.sender = address;
|
||||
return this;
|
||||
}
|
||||
public addSTXTransfer(params: {
|
||||
recipient: string;
|
||||
amount: number;
|
||||
sender?: string;
|
||||
fee?: number;
|
||||
}) {
|
||||
if (params.sender == null && this.sender === '') {
|
||||
throw new Error(
|
||||
'Please specify a sender with useSender or adding a sender paramenter'
|
||||
);
|
||||
}
|
||||
this.steps.push({
|
||||
...params,
|
||||
sender: params.sender ?? this.sender,
|
||||
fee: params.fee ?? 0,
|
||||
});
|
||||
return this;
|
||||
}
|
||||
public addContractCall(params: {
|
||||
contract_id: string;
|
||||
function_name: string;
|
||||
function_args?: ClarityValue[];
|
||||
sender?: string;
|
||||
fee?: number;
|
||||
}) {
|
||||
if (params.sender == null && this.sender === '') {
|
||||
throw new Error(
|
||||
'Please specify a sender with useSender or adding a sender paramenter'
|
||||
);
|
||||
}
|
||||
this.steps.push({
|
||||
...params,
|
||||
sender: params.sender ?? this.sender,
|
||||
fee: params.fee ?? 0,
|
||||
});
|
||||
return this;
|
||||
}
|
||||
public addContractDeploy(params: {
|
||||
contract_name: string;
|
||||
source_code: string;
|
||||
deployer?: string;
|
||||
fee?: number;
|
||||
clarity_version?: ClarityVersion;
|
||||
}) {
|
||||
if (params.deployer == null && this.sender === '') {
|
||||
throw new Error(
|
||||
'Please specify a deployer with useSender or adding a deployer paramenter'
|
||||
);
|
||||
}
|
||||
this.steps.push({
|
||||
...params,
|
||||
deployer: params.deployer ?? this.sender,
|
||||
fee: params.fee ?? 0,
|
||||
clarity_version: params.clarity_version ?? ClarityVersion.Clarity3,
|
||||
});
|
||||
return this;
|
||||
}
|
||||
public addEvalCode(inside_contract_id: string, code: string) {
|
||||
this.steps.push({
|
||||
contract_id: inside_contract_id,
|
||||
code,
|
||||
});
|
||||
return this;
|
||||
}
|
||||
public addMapRead(contract_id: string, map: string, key: string) {
|
||||
this.steps.push({
|
||||
contract_id,
|
||||
code: `(map-get ${map} ${key})`,
|
||||
});
|
||||
return this;
|
||||
}
|
||||
public addVarRead(contract_id: string, variable: string) {
|
||||
this.steps.push({
|
||||
contract_id,
|
||||
code: `(var-get ${variable})`,
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
private async getBlockInfo() {
|
||||
if (Number.isNaN(this.block)) {
|
||||
const { stacks_tip_height } = await getNodeInfo({
|
||||
stacksEndpoint: this.stacksNodeAPI,
|
||||
});
|
||||
this.block = stacks_tip_height;
|
||||
}
|
||||
const info: Block = await richFetch(
|
||||
`${this.stacksNodeAPI}/extended/v1/block/by_height/${this.block}?unanchored=true`
|
||||
).then((r) => r.json());
|
||||
if (
|
||||
info.height !== this.block ||
|
||||
typeof info.hash !== 'string' ||
|
||||
!info.hash.startsWith('0x')
|
||||
) {
|
||||
throw new Error(
|
||||
`failed to get block info for block height ${this.block}`
|
||||
);
|
||||
}
|
||||
return {
|
||||
block_height: this.block,
|
||||
block_hash: info.hash.substring(2),
|
||||
index_block_hash: info.index_block_hash.substring(2),
|
||||
};
|
||||
}
|
||||
|
||||
public async run() {
|
||||
console.log(
|
||||
`--------------------------------
|
||||
This product can never exist without your support!
|
||||
|
||||
We receive sponsorship funds with:
|
||||
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.`
|
||||
);
|
||||
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
|
||||
}/v2/accounts/${sender}?proof=${false}&tip=${block.index_block_hash}`;
|
||||
const account: AccountDataResponse = await richFetch(url).then((r) =>
|
||||
r.json()
|
||||
);
|
||||
nonce_by_address.set(sender, account.nonce + 1);
|
||||
return account.nonce;
|
||||
}
|
||||
nonce_by_address.set(sender, nonce + 1);
|
||||
return nonce;
|
||||
};
|
||||
for (const step of this.steps) {
|
||||
let network = this.network === 'mainnet' ? STACKS_MAINNET : STACKS_TESTNET;
|
||||
if (this.stacksNodeAPI) {
|
||||
network = {
|
||||
...network,
|
||||
client: {
|
||||
...network.client,
|
||||
baseUrl: this.stacksNodeAPI,
|
||||
},
|
||||
};
|
||||
}
|
||||
if ('sender' in step && 'function_name' in step) {
|
||||
const nonce = await nextNonce(step.sender);
|
||||
const [contractAddress, contractName] = step.contract_id.split('.');
|
||||
const tx = await makeUnsignedContractCall({
|
||||
contractAddress,
|
||||
contractName,
|
||||
functionName: step.function_name,
|
||||
functionArgs: step.function_args ?? [],
|
||||
nonce,
|
||||
network,
|
||||
publicKey: '',
|
||||
postConditionMode: PostConditionMode.Allow,
|
||||
fee: step.fee,
|
||||
});
|
||||
tx.auth.spendingCondition.signer = c32addressDecode(step.sender)[1];
|
||||
txs.push(tx);
|
||||
} else if ('sender' in step && 'recipient' in step) {
|
||||
const nonce = await nextNonce(step.sender);
|
||||
const tx = await makeUnsignedSTXTokenTransfer({
|
||||
recipient: step.recipient,
|
||||
amount: step.amount,
|
||||
nonce,
|
||||
network,
|
||||
publicKey: '',
|
||||
fee: step.fee,
|
||||
});
|
||||
tx.auth.spendingCondition.signer = c32addressDecode(step.sender)[1];
|
||||
txs.push(tx);
|
||||
} else if ('deployer' in step) {
|
||||
const nonce = await nextNonce(step.deployer);
|
||||
const tx = await makeUnsignedContractDeploy({
|
||||
contractName: step.contract_name,
|
||||
codeBody: step.source_code,
|
||||
nonce,
|
||||
network,
|
||||
publicKey: '',
|
||||
postConditionMode: PostConditionMode.Allow,
|
||||
fee: step.fee,
|
||||
});
|
||||
tx.auth.spendingCondition.signer = c32addressDecode(step.deployer)[1];
|
||||
txs.push(tx);
|
||||
} else if ('code' in step) {
|
||||
txs.push(step);
|
||||
} else {
|
||||
// biome-ignore lint/style/noUnusedTemplateLiteral: <explanation>
|
||||
console.log(`Invalid simulation step:`, step);
|
||||
}
|
||||
}
|
||||
const id = await runSimulation(
|
||||
`${this.apiEndpoint}/simulations`,
|
||||
block.block_hash,
|
||||
block.block_height,
|
||||
txs
|
||||
);
|
||||
console.log(
|
||||
`Simulation will be available at: https://stxer.xyz/simulations/${this.network}/${id}`
|
||||
);
|
||||
return id;
|
||||
}
|
||||
|
||||
public pipe(transform: (builder: SimulationBuilder) => SimulationBuilder): SimulationBuilder {
|
||||
return transform(this);
|
||||
}
|
||||
}
|
||||
export * from './batch-api';
|
||||
export * from './simulation';
|
||||
|
||||
125
src/sample/read.ts
Normal file
125
src/sample/read.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import {
|
||||
contractPrincipalCV,
|
||||
principalCV,
|
||||
stringAsciiCV,
|
||||
tupleCV,
|
||||
uintCV,
|
||||
} from '@stacks/transactions';
|
||||
import { batchRead, batchReadonly } from '../batch-api';
|
||||
|
||||
async function batchReadsExample() {
|
||||
const rs = await batchRead({
|
||||
// index_block_hash:
|
||||
// 'ce04817b9c6d90814ff9c06228d3a07d64335b1d9b01a233456fc304e34f7c0e', // block 373499
|
||||
variables: [
|
||||
{
|
||||
contract: contractPrincipalCV(
|
||||
'SP1Z92MPDQEWZXW36VX71Q25HKF5K2EPCJ304F275',
|
||||
'liquidity-token-v5kbe3oqvac'
|
||||
),
|
||||
variableName: 'balance-x',
|
||||
},
|
||||
{
|
||||
contract: contractPrincipalCV(
|
||||
'SP1Z92MPDQEWZXW36VX71Q25HKF5K2EPCJ304F275',
|
||||
'liquidity-token-v5kbe3oqvac'
|
||||
),
|
||||
variableName: 'balance-y',
|
||||
},
|
||||
{
|
||||
contract: contractPrincipalCV(
|
||||
'SP1Z92MPDQEWZXW36VX71Q25HKF5K2EPCJ304F275',
|
||||
'liquidity-token-v5kbe3oqvac'
|
||||
),
|
||||
variableName: 'something-not-exists',
|
||||
},
|
||||
],
|
||||
maps: [
|
||||
{
|
||||
contract: contractPrincipalCV(
|
||||
'SP102V8P0F7JX67ARQ77WEA3D3CFB5XW39REDT0AM',
|
||||
'amm-registry-v2-01'
|
||||
),
|
||||
mapName: 'pools-data-map',
|
||||
mapKey: tupleCV({
|
||||
'token-x': principalCV(
|
||||
'SP102V8P0F7JX67ARQ77WEA3D3CFB5XW39REDT0AM.token-wstx-v2'
|
||||
),
|
||||
'token-y': principalCV(
|
||||
'SP102V8P0F7JX67ARQ77WEA3D3CFB5XW39REDT0AM.token-alex'
|
||||
),
|
||||
factor: uintCV(1e8),
|
||||
}),
|
||||
},
|
||||
{
|
||||
contract: contractPrincipalCV(
|
||||
'SP1Y5YSTAHZ88XYK1VPDH24GY0HPX5J4JECTMY4A1',
|
||||
'univ2-core'
|
||||
),
|
||||
mapName: 'pools',
|
||||
mapKey: uintCV(1),
|
||||
},
|
||||
{
|
||||
contract: contractPrincipalCV(
|
||||
'SP1Y5YSTAHZ88XYK1VPDH24GY0HPX5J4JECTMY4A1',
|
||||
'contract-not-exists'
|
||||
),
|
||||
mapName: 'pools',
|
||||
mapKey: uintCV(1),
|
||||
},
|
||||
],
|
||||
});
|
||||
console.log(rs);
|
||||
}
|
||||
|
||||
async function batchReadonlyExample() {
|
||||
const rs = await batchReadonly({
|
||||
index_block_hash:
|
||||
'ce04817b9c6d90814ff9c06228d3a07d64335b1d9b01a233456fc304e34f7c0e',
|
||||
readonly: [
|
||||
{
|
||||
contract: contractPrincipalCV(
|
||||
'SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4',
|
||||
'sbtc-token'
|
||||
),
|
||||
functionName: 'get-total-supply',
|
||||
functionArgs: [],
|
||||
},
|
||||
{
|
||||
contract: contractPrincipalCV(
|
||||
'SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4',
|
||||
'sbtc-token'
|
||||
),
|
||||
functionName: 'get-balance',
|
||||
functionArgs: [
|
||||
principalCV('SP1CT7J2RWBZD62QAX36A2PQ3HKH5NFDGVHB8J34V'),
|
||||
],
|
||||
},
|
||||
{
|
||||
contract: contractPrincipalCV(
|
||||
'SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4',
|
||||
'sbtc-token'
|
||||
),
|
||||
functionName: 'function-not-exists',
|
||||
functionArgs: [],
|
||||
},
|
||||
{
|
||||
contract: contractPrincipalCV(
|
||||
'SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4',
|
||||
'sbtc-token'
|
||||
),
|
||||
functionName: 'get-balance',
|
||||
functionArgs: [stringAsciiCV('invalid-args')],
|
||||
},
|
||||
],
|
||||
});
|
||||
console.log(rs);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await batchReadsExample();
|
||||
await batchReadonlyExample();
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
|
||||
387
src/simulation.ts
Normal file
387
src/simulation.ts
Normal file
@@ -0,0 +1,387 @@
|
||||
import { type AccountDataResponse, getNodeInfo, richFetch } from 'ts-clarity';
|
||||
import type { Block } from '@stacks/stacks-blockchain-api-types';
|
||||
import {
|
||||
STACKS_MAINNET,
|
||||
STACKS_TESTNET,
|
||||
type StacksNetworkName,
|
||||
} from '@stacks/network';
|
||||
import {
|
||||
type ClarityValue,
|
||||
ClarityVersion,
|
||||
PostConditionMode,
|
||||
type StacksTransactionWire,
|
||||
bufferCV,
|
||||
contractPrincipalCV,
|
||||
makeUnsignedContractCall,
|
||||
makeUnsignedContractDeploy,
|
||||
makeUnsignedSTXTokenTransfer,
|
||||
serializeCVBytes,
|
||||
stringAsciiCV,
|
||||
tupleCV,
|
||||
uintCV,
|
||||
} from '@stacks/transactions';
|
||||
import { c32addressDecode } from 'c32check';
|
||||
|
||||
function runTx(tx: StacksTransactionWire) {
|
||||
// type 0: run transaction
|
||||
return tupleCV({ type: uintCV(0), data: bufferCV(tx.serializeBytes()) });
|
||||
}
|
||||
|
||||
export interface SimulationEval {
|
||||
contract_id: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
export function runEval({ contract_id, code }: SimulationEval) {
|
||||
const [contract_address, contract_name] = contract_id.split('.');
|
||||
// type 1: eval arbitrary code inside a contract
|
||||
return tupleCV({
|
||||
type: uintCV(1),
|
||||
data: bufferCV(
|
||||
serializeCVBytes(
|
||||
tupleCV({
|
||||
contract: contractPrincipalCV(contract_address, contract_name),
|
||||
code: stringAsciiCV(code),
|
||||
})
|
||||
)
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export async function runSimulation(
|
||||
apiEndpoint: string,
|
||||
block_hash: string,
|
||||
block_height: number,
|
||||
txs: (StacksTransactionWire | SimulationEval)[]
|
||||
) {
|
||||
// Convert 'sim-v1' to Uint8Array
|
||||
const header = new TextEncoder().encode('sim-v1');
|
||||
// Create 8 bytes for block height
|
||||
const heightBytes = new Uint8Array(8);
|
||||
// Convert block height to bytes
|
||||
const view = new DataView(heightBytes.buffer);
|
||||
view.setBigUint64(0, BigInt(block_height), false); // false for big-endian
|
||||
|
||||
// Convert block hash to bytes
|
||||
const hashHex = block_hash.startsWith('0x')
|
||||
? block_hash.substring(2)
|
||||
: block_hash;
|
||||
// Replace non-null assertion with null check
|
||||
const matches = hashHex.match(/.{1,2}/g);
|
||||
if (!matches) {
|
||||
throw new Error('Invalid block hash format');
|
||||
}
|
||||
const hashBytes = new Uint8Array(
|
||||
matches.map((byte) => Number.parseInt(byte, 16))
|
||||
);
|
||||
|
||||
// Convert transactions to bytes
|
||||
const txBytes = txs
|
||||
.map((t) => ('contract_id' in t && 'code' in t ? runEval(t) : runTx(t)))
|
||||
.map((t) => serializeCVBytes(t));
|
||||
|
||||
// Combine all byte arrays
|
||||
const totalLength =
|
||||
header.length +
|
||||
heightBytes.length +
|
||||
hashBytes.length +
|
||||
txBytes.reduce((acc, curr) => acc + curr.length, 0);
|
||||
const body = new Uint8Array(totalLength);
|
||||
|
||||
let offset = 0;
|
||||
body.set(header, offset);
|
||||
offset += header.length;
|
||||
body.set(heightBytes, offset);
|
||||
offset += heightBytes.length;
|
||||
body.set(hashBytes, offset);
|
||||
offset += hashBytes.length;
|
||||
for (const tx of txBytes) {
|
||||
body.set(tx, offset);
|
||||
offset += tx.length;
|
||||
}
|
||||
|
||||
const rs = await fetch(apiEndpoint, {
|
||||
method: 'POST',
|
||||
body,
|
||||
}).then(async (rs) => {
|
||||
const response = await rs.text();
|
||||
if (!response.startsWith('{')) {
|
||||
throw new Error(`failed to submit simulation: ${response}`);
|
||||
}
|
||||
return JSON.parse(response) as { id: string };
|
||||
});
|
||||
return rs.id;
|
||||
}
|
||||
|
||||
interface SimulationBuilderOptions {
|
||||
apiEndpoint?: string;
|
||||
stacksNodeAPI?: string;
|
||||
network?: StacksNetworkName | string;
|
||||
}
|
||||
|
||||
export class SimulationBuilder {
|
||||
private apiEndpoint: string;
|
||||
private stacksNodeAPI: string;
|
||||
private network: StacksNetworkName | string;
|
||||
|
||||
private constructor(options: SimulationBuilderOptions = {}) {
|
||||
this.apiEndpoint = options.apiEndpoint ?? 'https://api.stxer.xyz';
|
||||
this.stacksNodeAPI = options.stacksNodeAPI ?? 'https://api.hiro.so';
|
||||
this.network = options.network ?? 'mainnet';
|
||||
}
|
||||
|
||||
public static new(options?: SimulationBuilderOptions) {
|
||||
return new SimulationBuilder(options);
|
||||
}
|
||||
|
||||
// biome-ignore lint/style/useNumberNamespace: <explanation>
|
||||
private block = NaN;
|
||||
private sender = '';
|
||||
private steps: (
|
||||
| {
|
||||
// contract call
|
||||
contract_id: string;
|
||||
function_name: string;
|
||||
function_args?: ClarityValue[];
|
||||
sender: string;
|
||||
fee: number;
|
||||
}
|
||||
| {
|
||||
// contract deploy
|
||||
contract_name: string;
|
||||
source_code: string;
|
||||
deployer: string;
|
||||
fee: number;
|
||||
clarity_version: ClarityVersion;
|
||||
}
|
||||
| {
|
||||
// STX transfer
|
||||
recipient: string;
|
||||
amount: number;
|
||||
sender: string;
|
||||
fee: number;
|
||||
}
|
||||
| SimulationEval
|
||||
)[] = [];
|
||||
|
||||
public useBlockHeight(block: number) {
|
||||
this.block = block;
|
||||
return this;
|
||||
}
|
||||
public withSender(address: string) {
|
||||
this.sender = address;
|
||||
return this;
|
||||
}
|
||||
public addSTXTransfer(params: {
|
||||
recipient: string;
|
||||
amount: number;
|
||||
sender?: string;
|
||||
fee?: number;
|
||||
}) {
|
||||
if (params.sender == null && this.sender === '') {
|
||||
throw new Error(
|
||||
'Please specify a sender with useSender or adding a sender paramenter'
|
||||
);
|
||||
}
|
||||
this.steps.push({
|
||||
...params,
|
||||
sender: params.sender ?? this.sender,
|
||||
fee: params.fee ?? 0,
|
||||
});
|
||||
return this;
|
||||
}
|
||||
public addContractCall(params: {
|
||||
contract_id: string;
|
||||
function_name: string;
|
||||
function_args?: ClarityValue[];
|
||||
sender?: string;
|
||||
fee?: number;
|
||||
}) {
|
||||
if (params.sender == null && this.sender === '') {
|
||||
throw new Error(
|
||||
'Please specify a sender with useSender or adding a sender paramenter'
|
||||
);
|
||||
}
|
||||
this.steps.push({
|
||||
...params,
|
||||
sender: params.sender ?? this.sender,
|
||||
fee: params.fee ?? 0,
|
||||
});
|
||||
return this;
|
||||
}
|
||||
public addContractDeploy(params: {
|
||||
contract_name: string;
|
||||
source_code: string;
|
||||
deployer?: string;
|
||||
fee?: number;
|
||||
clarity_version?: ClarityVersion;
|
||||
}) {
|
||||
if (params.deployer == null && this.sender === '') {
|
||||
throw new Error(
|
||||
'Please specify a deployer with useSender or adding a deployer paramenter'
|
||||
);
|
||||
}
|
||||
this.steps.push({
|
||||
...params,
|
||||
deployer: params.deployer ?? this.sender,
|
||||
fee: params.fee ?? 0,
|
||||
clarity_version: params.clarity_version ?? ClarityVersion.Clarity3,
|
||||
});
|
||||
return this;
|
||||
}
|
||||
public addEvalCode(inside_contract_id: string, code: string) {
|
||||
this.steps.push({
|
||||
contract_id: inside_contract_id,
|
||||
code,
|
||||
});
|
||||
return this;
|
||||
}
|
||||
public addMapRead(contract_id: string, map: string, key: string) {
|
||||
this.steps.push({
|
||||
contract_id,
|
||||
code: `(map-get ${map} ${key})`,
|
||||
});
|
||||
return this;
|
||||
}
|
||||
public addVarRead(contract_id: string, variable: string) {
|
||||
this.steps.push({
|
||||
contract_id,
|
||||
code: `(var-get ${variable})`,
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
private async getBlockInfo() {
|
||||
if (Number.isNaN(this.block)) {
|
||||
const { stacks_tip_height } = await getNodeInfo({
|
||||
stacksEndpoint: this.stacksNodeAPI,
|
||||
});
|
||||
this.block = stacks_tip_height;
|
||||
}
|
||||
const info: Block = await richFetch(
|
||||
`${this.stacksNodeAPI}/extended/v1/block/by_height/${this.block}?unanchored=true`
|
||||
).then((r) => r.json());
|
||||
if (
|
||||
info.height !== this.block ||
|
||||
typeof info.hash !== 'string' ||
|
||||
!info.hash.startsWith('0x')
|
||||
) {
|
||||
throw new Error(
|
||||
`failed to get block info for block height ${this.block}`
|
||||
);
|
||||
}
|
||||
return {
|
||||
block_height: this.block,
|
||||
block_hash: info.hash.substring(2),
|
||||
index_block_hash: info.index_block_hash.substring(2),
|
||||
};
|
||||
}
|
||||
|
||||
public async run() {
|
||||
console.log(
|
||||
`--------------------------------
|
||||
This product can never exist without your support!
|
||||
|
||||
We receive sponsorship funds with:
|
||||
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.`
|
||||
);
|
||||
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
|
||||
}/v2/accounts/${sender}?proof=${false}&tip=${block.index_block_hash}`;
|
||||
const account: AccountDataResponse = await richFetch(url).then((r) =>
|
||||
r.json()
|
||||
);
|
||||
nonce_by_address.set(sender, account.nonce + 1);
|
||||
return account.nonce;
|
||||
}
|
||||
nonce_by_address.set(sender, nonce + 1);
|
||||
return nonce;
|
||||
};
|
||||
for (const step of this.steps) {
|
||||
let network = this.network === 'mainnet' ? STACKS_MAINNET : STACKS_TESTNET;
|
||||
if (this.stacksNodeAPI) {
|
||||
network = {
|
||||
...network,
|
||||
client: {
|
||||
...network.client,
|
||||
baseUrl: this.stacksNodeAPI,
|
||||
},
|
||||
};
|
||||
}
|
||||
if ('sender' in step && 'function_name' in step) {
|
||||
const nonce = await nextNonce(step.sender);
|
||||
const [contractAddress, contractName] = step.contract_id.split('.');
|
||||
const tx = await makeUnsignedContractCall({
|
||||
contractAddress,
|
||||
contractName,
|
||||
functionName: step.function_name,
|
||||
functionArgs: step.function_args ?? [],
|
||||
nonce,
|
||||
network,
|
||||
publicKey: '',
|
||||
postConditionMode: PostConditionMode.Allow,
|
||||
fee: step.fee,
|
||||
});
|
||||
tx.auth.spendingCondition.signer = c32addressDecode(step.sender)[1];
|
||||
txs.push(tx);
|
||||
} else if ('sender' in step && 'recipient' in step) {
|
||||
const nonce = await nextNonce(step.sender);
|
||||
const tx = await makeUnsignedSTXTokenTransfer({
|
||||
recipient: step.recipient,
|
||||
amount: step.amount,
|
||||
nonce,
|
||||
network,
|
||||
publicKey: '',
|
||||
fee: step.fee,
|
||||
});
|
||||
tx.auth.spendingCondition.signer = c32addressDecode(step.sender)[1];
|
||||
txs.push(tx);
|
||||
} else if ('deployer' in step) {
|
||||
const nonce = await nextNonce(step.deployer);
|
||||
const tx = await makeUnsignedContractDeploy({
|
||||
contractName: step.contract_name,
|
||||
codeBody: step.source_code,
|
||||
nonce,
|
||||
network,
|
||||
publicKey: '',
|
||||
postConditionMode: PostConditionMode.Allow,
|
||||
fee: step.fee,
|
||||
});
|
||||
tx.auth.spendingCondition.signer = c32addressDecode(step.deployer)[1];
|
||||
txs.push(tx);
|
||||
} else if ('code' in step) {
|
||||
txs.push(step);
|
||||
} else {
|
||||
// biome-ignore lint/style/noUnusedTemplateLiteral: <explanation>
|
||||
console.log(`Invalid simulation step:`, step);
|
||||
}
|
||||
}
|
||||
const id = await runSimulation(
|
||||
`${this.apiEndpoint}/simulations`,
|
||||
block.block_hash,
|
||||
block.block_height,
|
||||
txs
|
||||
);
|
||||
console.log(
|
||||
`Simulation will be available at: https://stxer.xyz/simulations/${this.network}/${id}`
|
||||
);
|
||||
return id;
|
||||
}
|
||||
|
||||
public pipe(transform: (builder: SimulationBuilder) => SimulationBuilder): SimulationBuilder {
|
||||
return transform(this);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user