diff --git a/package.json b/package.json index b4e7229..1c7454c 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/batch-api.ts b/src/batch-api.ts new file mode 100644 index 0000000..a40adc5 --- /dev/null +++ b/src/batch-api.ts @@ -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 { + 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; + } + \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 0c20ea8..aa692ee 100644 --- a/src/index.ts +++ b/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: - 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(); - 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: - 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'; diff --git a/src/sample/read.ts b/src/sample/read.ts new file mode 100644 index 0000000..8709f6e --- /dev/null +++ b/src/sample/read.ts @@ -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); + \ No newline at end of file diff --git a/src/simulation.ts b/src/simulation.ts new file mode 100644 index 0000000..bab1e74 --- /dev/null +++ b/src/simulation.ts @@ -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: + 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(); + 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: + 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); + } +}