diff --git a/standard/wallets/comparison/.gitignore b/standard/wallets/comparison/.gitignore new file mode 100644 index 0000000..3c35454 --- /dev/null +++ b/standard/wallets/comparison/.gitignore @@ -0,0 +1,11 @@ +node_modules/ +dist/ +build/ +*.tsbuildinfo +.env +.env.local +coverage/ +*.log +.DS_Store +temp/ + diff --git a/standard/wallets/comparison/.prettierrc b/standard/wallets/comparison/.prettierrc new file mode 100644 index 0000000..1e7c0d0 --- /dev/null +++ b/standard/wallets/comparison/.prettierrc @@ -0,0 +1,8 @@ +{ + "semi": true, + "trailingComma": "all", + "singleQuote": true, + "printWidth": 100, + "tabWidth": 2, + "arrowParens": "always" +} diff --git a/standard/wallets/comparison/README.md b/standard/wallets/comparison/README.md new file mode 100644 index 0000000..d49ff61 --- /dev/null +++ b/standard/wallets/comparison/README.md @@ -0,0 +1,17 @@ +# Wallet Fee Comparison + +This workspace provides a reproducible test harness for benchmarking transaction fees across several TON wallet implementations. The suite focuses on measuring gas usage, total fees, and per-message costs for different payload sizes and batch configurations. + +## Layout + +- `tests/WalletFeeComparison.spec.ts` — main Jest suite that orchestrates the fee measurements and outputs markdown reports. +- `tests/utils` — helper utilities for fee extraction and TON gas calculations. +- `wrappers/` — contract wrappers required to deploy and interact with wallets inside the sandbox. +- `build/` — precompiled wallet artifacts referenced by the wrappers. + +## Getting Started + +1. Install dependencies: `yarn install` +2. Run the benchmark suite: `yarn test` + +The tests spawn sandbox blockchains locally, so no external network access is required. Results are written to `tests/results/wallet-fee-comparison.md`. diff --git a/standard/wallets/comparison/jest.config.js b/standard/wallets/comparison/jest.config.js new file mode 100644 index 0000000..8ba99d7 --- /dev/null +++ b/standard/wallets/comparison/jest.config.js @@ -0,0 +1,13 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testPathIgnorePatterns: ['/node_modules/', '/dist/'], + collectCoverage: false, + coverageDirectory: 'coverage', + coveragePathIgnorePatterns: ['/node_modules/', '/dist/'], + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], + transform: { + '^.+\\.ts$': 'ts-jest', + }, + testMatch: ['**/tests/**/*.spec.ts'], +}; diff --git a/standard/wallets/comparison/package.json b/standard/wallets/comparison/package.json new file mode 100644 index 0000000..28879e4 --- /dev/null +++ b/standard/wallets/comparison/package.json @@ -0,0 +1,28 @@ +{ + "name": "example", + "version": "0.0.1", + "description": "Automated fee comparison tests for multiple TON wallet implementations", + "scripts": { + "build": "tsc", + "test": "jest", + "deploy": "ts-node scripts/deploy.ts", + "lint": "prettier --check .", + "format": "prettier --write ." + }, + "devDependencies": { + "@ton/blueprint": "^0.40.0", + "@ton/core": "^0.62.0", + "@ton/crypto": "^3.2.0", + "@ton/sandbox": "^0.37.2", + "@ton/test-utils": "^0.12.0", + "@ton/ton": "^15.3.1", + "@types/jest": "^29.5.0", + "@types/node": "^20.2.5", + "jest": "^29.5.0", + "prettier": "^3.6.2", + "ts-jest": "^29.0.5", + "ts-node": "^10.9.1", + "typescript": "^5.3.2" + }, + "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" +} diff --git a/standard/wallets/comparison/tests/get-results.ts b/standard/wallets/comparison/tests/get-results.ts new file mode 100644 index 0000000..1fadbd9 --- /dev/null +++ b/standard/wallets/comparison/tests/get-results.ts @@ -0,0 +1,524 @@ +import { Blockchain } from '@ton/sandbox'; +import { + Cell, + MessageRelaxed, + internal as internal_relaxed, + fromNano, + SendMode, + toNano, + OutActionSendMsg, +} from '@ton/core'; +import { + WalletContractV2R1, + WalletContractV2R2, + WalletContractV3R1, + WalletContractV3R2, + WalletContractV4, + WalletContractV5R1, +} from '@ton/ton'; +import { KeyPair, keyPairFromSeed, getSecureRandomBytes } from '@ton/crypto'; +import { randomAddress } from '@ton/test-utils'; +import { HighloadWalletV3Code, HighloadWalletV3 } from '../wrappers/highload-wallet-v3'; +import { HighloadQueryId } from '../wrappers/highload-query-id'; +import { Wallet as PreprocessedWalletV2 } from '../wrappers/preprocessed-wallet-v2'; +import { SUBWALLET_ID, DEFAULT_TIMEOUT } from './imports/const'; +import { extractTransactionFees } from './utils/fee-extraction'; +import { setStoragePrices } from './utils/gas-utils'; + +export type MessageBodyResolver = (messageIndex: number) => Cell; + +export type WalletTestResult = { + walletName: string; + requests: number; + totalGas: bigint; + totalFee: bigint; + messageCount: number; + bodyName: string; +}; + +export type TestConstants = { + messageValue: bigint; + deployValue: bigint; +}; + +export type MeasureWalletFunction = ( + messageCount: number, + bodyResolver: MessageBodyResolver, + bodyName: string, + constants: TestConstants, +) => Promise; + +export type WalletConfig = { + key: string; + name: string; + measureFunction: MeasureWalletFunction; +}; + +export const toCoins = (value: bigint): number => { + return Number(fromNano(value)); +}; + +const extractGasUsed = (tx: any): bigint => { + if (tx.description.type !== 'generic') return 0n; + if (tx.description.computePhase.type !== 'vm') return 0n; + return tx.description.computePhase.gasUsed as bigint; +}; + +const createMessages = ( + startIndex: number, + count: number, + resolveBody: MessageBodyResolver, + messageValue: bigint, +): MessageRelaxed[] => + Array.from({ length: count }, (_, offset) => + internal_relaxed({ + to: randomAddress(), + value: messageValue, + bounce: false, + body: resolveBody(startIndex + offset), + }), + ); + +const setup = async () => { + const blockchain = await Blockchain.create(); + + const config = blockchain.config; + blockchain.setConfig( + setStoragePrices(config, { + utime_sice: 0, + bit_price_ps: 0n, + cell_price_ps: 0n, + mc_bit_price_ps: 0n, + mc_cell_price_ps: 0n, + }), + ); + + const keyPair = keyPairFromSeed(await getSecureRandomBytes(32)); + return { blockchain, keyPair }; +}; + +async function measureStandardWallet( + walletName: string, + createWallet: (blockchain: Blockchain, keyPair: KeyPair) => any, + batchSize: number, + messageCount: number, + bodyResolver: MessageBodyResolver, + bodyName: string, + constants: TestConstants, +): Promise { + const { blockchain, keyPair } = await setup(); + const wallet = createWallet(blockchain, keyPair); + + // Deploy wallet + const deployer = await blockchain.treasury('deployer'); + await deployer.send({ + value: constants.deployValue, + to: wallet.address, + init: wallet.init, + }); + + const balanceBefore = (await blockchain.getContract(wallet.address)).balance; + + let totalGas = 0n; + let totalFee = 0n; + let totalGasVirtual = 0n; + let totalFeeVirtual = 0n; + let requests = 0; + let sentMessagesCount = 0; + + let seqno: bigint = BigInt(await wallet.getSeqno()); + let nextMessageIndex = 0; + let lastBatch: { batchCount: number; gas: bigint; fee: bigint } | null = null; + + for (let i = 0; i < messageCount; i += batchSize) { + const batchCount = Math.min(batchSize, messageCount - i); + + if (lastBatch && batchCount === lastBatch.batchCount) { + totalGasVirtual += lastBatch.gas; + totalFeeVirtual += lastBatch.fee; + requests++; + continue; + } + + const messages = createMessages( + nextMessageIndex, + batchCount, + bodyResolver, + constants.messageValue, + ); + + const transfer = await wallet.createTransfer({ + seqno: Number(seqno), + secretKey: keyPair.secretKey, + messages, + sendMode: SendMode.NONE, + }); + const result = await wallet.send(transfer); + + const externalTx = result.transactions.find( + (tx: any) => tx.inMessage?.info.type === 'external-in', + ); + if (!externalTx) throw new Error('No external-in transaction'); + + const gas = extractGasUsed(externalTx); + const txFees = extractTransactionFees(externalTx, blockchain); + const fee = txFees.import_fee + txFees.storage_fee + txFees.gas_fees; + + totalGas += gas; + totalFee += fee; + sentMessagesCount += batchCount; + requests++; + + seqno = seqno + 1n; + nextMessageIndex += batchCount; + lastBatch = { batchCount, gas, fee }; + } + + const balanceAfter = (await blockchain.getContract(wallet.address)).balance; + const balanceDiff = balanceBefore - balanceAfter; + const totalMessageValue = constants.messageValue * BigInt(sentMessagesCount); + + if (balanceDiff !== totalMessageValue + totalFee) { + throw new Error( + `Balance mismatch: expected ${totalMessageValue + totalFee}, got ${balanceDiff}`, + ); + } + + return { + walletName, + requests, + totalGas: totalGas + totalGasVirtual, + totalFee: totalFee + totalFeeVirtual, + messageCount, + bodyName, + }; +} + +async function measurePreprocessedWalletV2( + messageCount: number, + bodyResolver: MessageBodyResolver, + bodyName: string, + constants: TestConstants, +): Promise { + const walletName = 'Preprocessed Wallet V2'; + const batchSize = 255; + const { blockchain, keyPair } = await setup(); + + const wallet = blockchain.openContract( + PreprocessedWalletV2.createFromPublicKey(keyPair.publicKey), + ); + + // Deploy + const deployer = await blockchain.treasury('deployer'); + await wallet.sendDeploy(deployer.getSender(), constants.deployValue); + + const balanceBefore = (await blockchain.getContract(wallet.address)).balance; + + let totalGas = 0n; + let totalFee = 0n; + let totalGasVirtual = 0n; + let totalFeeVirtual = 0n; + let requests = 0; + let sentMessagesCount = 0; + + let seqno: bigint = BigInt(await wallet.getSeqno()); + let nextMessageIndex = 0; + let lastBatch: { batchCount: number; gas: bigint; fee: bigint } | null = null; + + for (let i = 0; i < messageCount; i += batchSize) { + const batchCount = Math.min(batchSize, messageCount - i); + + if (lastBatch && batchCount === lastBatch.batchCount) { + totalGasVirtual += lastBatch.gas; + totalFeeVirtual += lastBatch.fee; + requests++; + continue; + } + + const transfers = Array.from({ length: batchCount }, (_, offset) => ({ + to: randomAddress(), + value: constants.messageValue, + bounce: false, + body: bodyResolver(nextMessageIndex + offset), + mode: SendMode.NONE, + })); + + const result = await wallet.sendTransfers(keyPair, transfers, Number(seqno)); + + const externalTx = result.transactions.find( + (tx: any) => tx.inMessage?.info.type === 'external-in', + ); + if (!externalTx) throw new Error('No external-in transaction'); + + const gas = extractGasUsed(externalTx); + const txFees = extractTransactionFees(externalTx, blockchain); + const fee = txFees.import_fee + txFees.storage_fee + txFees.gas_fees; + + totalGas += gas; + totalFee += fee; + sentMessagesCount += batchCount; + requests++; + + seqno = seqno + 1n; + nextMessageIndex += batchCount; + lastBatch = { batchCount, gas, fee }; + } + + const balanceAfter = (await blockchain.getContract(wallet.address)).balance; + const balanceDiff = balanceBefore - balanceAfter; + const totalMessageValue = constants.messageValue * BigInt(sentMessagesCount); + + if (balanceDiff !== totalMessageValue + totalFee) { + throw new Error( + `Balance mismatch: expected ${totalMessageValue + totalFee}, got ${balanceDiff}`, + ); + } + + return { + walletName, + requests, + totalGas: totalGas + totalGasVirtual, + totalFee: totalFee + totalFeeVirtual, + messageCount, + bodyName, + }; +} + +async function measureHighloadV3( + messageCount: number, + bodyResolver: MessageBodyResolver, + bodyName: string, + constants: TestConstants, +): Promise { + const walletName = 'Highload Wallet V3'; + const { blockchain, keyPair } = await setup(); + blockchain.now = 1000; + let queryId = new HighloadQueryId(); + + const wallet = blockchain.openContract( + HighloadWalletV3.createFromConfig( + { publicKey: keyPair.publicKey, subwalletId: SUBWALLET_ID, timeout: DEFAULT_TIMEOUT }, + HighloadWalletV3Code, + ), + ); + + const deployer = await blockchain.treasury('deployer'); + await wallet.sendDeploy(deployer.getSender(), constants.deployValue); + + const balanceBefore = (await blockchain.getContract(wallet.address)).balance; + const totalMessageValue = constants.messageValue * BigInt(messageCount); + + let totalGas = 0n; + let totalFee = 0n; + let requests = 0; + const batchSize = 254; + + for (let i = 0; i < messageCount; i += batchSize) { + const batchCount = Math.min(batchSize, messageCount - i); + const actions: OutActionSendMsg[] = Array.from({ length: batchCount }, (_, offset) => ({ + type: 'sendMsg', + mode: SendMode.NONE, + outMsg: internal_relaxed({ + to: randomAddress(), + value: constants.messageValue, + bounce: false, + body: bodyResolver(i + offset), + }), + })); + + const result = await wallet.sendBatch( + keyPair.secretKey, + actions, + SUBWALLET_ID, + queryId, + DEFAULT_TIMEOUT, + blockchain.now, + ); + queryId = queryId.getNext(); + + const externalTx = result.transactions.find( + (tx: any) => tx.inMessage?.info.type === 'external-in', + ); + if (!externalTx) throw new Error('No external-in transaction'); + + const externalFees = extractTransactionFees(externalTx, blockchain); + const externalFee = + externalFees.import_fee + + externalFees.storage_fee + + externalFees.gas_fees + + externalFees.out_fwd_fees; + + const internalTx = result.transactions.find( + (tx: any) => + tx.inMessage?.info.type === 'internal' && + tx.inMessage?.info.src?.equals?.(wallet.address) && + tx.inMessage?.info.dest?.equals?.(wallet.address), + ); + if (!internalTx) throw new Error('No internal self-call transaction'); + + const internalFees = extractTransactionFees(internalTx, blockchain); + const internalFee = internalFees.storage_fee + internalFees.gas_fees; + + totalGas += extractGasUsed(externalTx) + extractGasUsed(internalTx); + totalFee += externalFee + internalFee; + requests++; + } + + const balanceAfter = (await blockchain.getContract(wallet.address)).balance; + const balanceDiff = balanceBefore - balanceAfter; + + if (balanceDiff !== totalMessageValue + totalFee) { + throw new Error( + `Balance mismatch: expected ${totalMessageValue + totalFee}, got ${balanceDiff}`, + ); + } + + return { + walletName, + requests, + totalGas, + totalFee, + messageCount, + bodyName, + }; +} + +function createStandardWalletMeasureFunction( + walletName: string, + createWallet: (blockchain: Blockchain, keyPair: KeyPair) => any, + batchSize: number, +): MeasureWalletFunction { + return async (messageCount, bodyResolver, bodyName, constants) => + measureStandardWallet( + walletName, + createWallet, + batchSize, + messageCount, + bodyResolver, + bodyName, + constants, + ); +} + +export const WALLET_CONFIGS: WalletConfig[] = [ + { + key: 'v2r1', + name: 'Wallet V2R1', + measureFunction: createStandardWalletMeasureFunction( + 'Wallet V2R1', + (blockchain, kp) => + blockchain.openContract( + WalletContractV2R1.create({ workchain: 0, publicKey: kp.publicKey }), + ), + 4, + ), + }, + { + key: 'v2r2', + name: 'Wallet V2R2', + measureFunction: createStandardWalletMeasureFunction( + 'Wallet V2R2', + (blockchain, kp) => + blockchain.openContract( + WalletContractV2R2.create({ workchain: 0, publicKey: kp.publicKey }), + ), + 4, + ), + }, + { + key: 'v3r1', + name: 'Wallet V3R1', + measureFunction: createStandardWalletMeasureFunction( + 'Wallet V3R1', + (blockchain, kp) => + blockchain.openContract( + WalletContractV3R1.create({ workchain: 0, publicKey: kp.publicKey }), + ), + 4, + ), + }, + { + key: 'v3r2', + name: 'Wallet V3R2', + measureFunction: createStandardWalletMeasureFunction( + 'Wallet V3R2', + (blockchain, kp) => + blockchain.openContract( + WalletContractV3R2.create({ workchain: 0, publicKey: kp.publicKey }), + ), + 4, + ), + }, + { + key: 'v4r2', + name: 'Wallet V4R2', + measureFunction: createStandardWalletMeasureFunction( + 'Wallet V4R2', + (blockchain, kp) => + blockchain.openContract(WalletContractV4.create({ workchain: 0, publicKey: kp.publicKey })), + 4, + ), + }, + { + key: 'v5r1', + name: 'Wallet V5R1', + measureFunction: createStandardWalletMeasureFunction( + 'Wallet V5R1', + (blockchain, kp) => + blockchain.openContract( + WalletContractV5R1.create({ workchain: 0, publicKey: kp.publicKey }), + ), + 255, + ), + }, + { + key: 'preprocessedV2', + name: 'Preprocessed Wallet V2', + measureFunction: measurePreprocessedWalletV2, + }, + { + key: 'highloadV3', + name: 'Highload Wallet V3', + measureFunction: measureHighloadV3, + }, +]; + +export const DEFAULT_CONSTANTS: TestConstants = { + messageValue: toNano('0.01'), + deployValue: toNano('1000'), +}; + +export async function runAllMeasurements(config: { + enabledWallets: Record; + testRuns: Array<{ + messageCount: number; + bodyResolver: MessageBodyResolver; + bodyName: string; + }>; + constants: TestConstants; +}): Promise { + const keyPair = keyPairFromSeed(await getSecureRandomBytes(32)); + const allResults: WalletTestResult[][] = []; + + const enabledWalletConfigs = WALLET_CONFIGS.filter( + (walletConfig) => config.enabledWallets[walletConfig.key], + ); + + for (const testRun of config.testRuns) { + const results: WalletTestResult[] = []; + + for (const walletConfig of enabledWalletConfigs) { + const result = await walletConfig.measureFunction( + testRun.messageCount, + testRun.bodyResolver, + testRun.bodyName, + config.constants, + ); + results.push(result); + } + + allResults.push(results); + } + + return allResults; +} diff --git a/standard/wallets/comparison/tests/imports/const.ts b/standard/wallets/comparison/tests/imports/const.ts new file mode 100644 index 0000000..41c3270 --- /dev/null +++ b/standard/wallets/comparison/tests/imports/const.ts @@ -0,0 +1,18 @@ +export const SUBWALLET_ID = 239; + +export const DEFAULT_TIMEOUT = 128; + +export enum OP { + InternalTransfer = 0xae42e5a4, +} +export abstract class Errors { + static invalid_signature = 33; + static invalid_subwallet = 34; + static invalid_creation_time = 35; + static already_executed = 36; +} + +export const maxKeyCount = 1 << 13; //That is max key count not max key value +export const maxShift = maxKeyCount - 1; +export const maxQueryCount = maxKeyCount * 1023; // Therefore value count +export const maxQueryId = (maxShift << 10) + 1022; diff --git a/standard/wallets/comparison/tests/print-tables.ts b/standard/wallets/comparison/tests/print-tables.ts new file mode 100644 index 0000000..d759e1e --- /dev/null +++ b/standard/wallets/comparison/tests/print-tables.ts @@ -0,0 +1,390 @@ +import { writeFileSync, mkdirSync } from 'fs'; +import path from 'path'; +import { WalletTestResult, toCoins } from './get-results'; + +type ColumnAlignment = 'left' | 'center' | 'right'; + +type ColumnConfig = { + key: string; + header: string; + alignment: ColumnAlignment; + enabled: boolean; +}; + +type RequestTimings = { + realSeconds: number; + theoreticalSeconds: number; +}; + +type TestRun = { + messageCount: number; + bodyName: string; +}; + +export type PrintTablesConfig = { + allResults: WalletTestResult[][]; + testRuns: TestRun[]; + columnOrder: ColumnConfig[]; + requestTimings: RequestTimings; + highloadWalletName: string; + preprocessedWalletName: string; + outputDirectory: string; +}; + +export const formatSeconds = (seconds: number): string => { + const totalSeconds = Math.round(seconds); + if (totalSeconds < 60) { + return `${totalSeconds}s`; + } + const minutes = Math.floor(totalSeconds / 60); + const secs = totalSeconds % 60; + return `${minutes}m ${secs}s`; +}; + +const trimCommonTrailingZeros = (values: string[]): string[] => { + if (values.length === 0) return values; + + const numericValues = values.filter((v) => /^\d+\.\d+$/.test(v)); + if (numericValues.length !== values.length) return values; + + let commonTrailingZeros = Infinity; + for (const value of values) { + const decimals = value.split('.')[1]; + let trailingZeros = 0; + for (let i = decimals.length - 1; i >= 0; i--) { + if (decimals[i] === '0') { + trailingZeros++; + } else { + break; + } + } + commonTrailingZeros = Math.min(commonTrailingZeros, trailingZeros); + } + + if (commonTrailingZeros >= 2) { + return values.map((v) => { + const [integer, decimals] = v.split('.'); + const trimmedDecimals = decimals.slice(0, decimals.length - commonTrailingZeros); + return trimmedDecimals ? `${integer}.${trimmedDecimals}` : integer; + }); + } + + return values; +}; + +const formatPercentDiffPlain = (value: bigint, baseline: bigint): string => { + if (baseline === 0n) { + return 'N/A'; + } + const diff = Number(((value - baseline) * 10000n) / baseline) / 100; + if (!Number.isFinite(diff)) { + return 'N/A'; + } + if (diff === 0) { + return '0.00%'; + } + const prefix = diff > 0 ? '+' : ''; + return `${prefix}${diff.toFixed(2)}%`; +}; + +const formatPercentDiffMarkdown = (value: bigint, baseline: bigint): string => { + if (baseline === 0n) { + return 'N/A'; + } + if (value === baseline) { + return '**Best**'; + } + return formatPercentDiffPlain(value, baseline); +}; + +const getAlignmentMarker = (alignment: ColumnAlignment): string => { + switch (alignment) { + case 'left': + return ':---'; + case 'center': + return ':---:'; + case 'right': + return '---:'; + } +}; + +export async function printTables(config: PrintTablesConfig) { + const { + allResults, + testRuns, + columnOrder, + requestTimings, + highloadWalletName, + preprocessedWalletName, + outputDirectory, + } = config; + + if (allResults.length === 0) return; + + const markdownLines: string[] = ['# Wallet Fee Comparison Results', '']; + const numberFormatter = new Intl.NumberFormat('en-US'); + const tonFormatter = new Intl.NumberFormat('en-US', { + minimumFractionDigits: 9, + maximumFractionDigits: 9, + }); + + const resultsByBody = new Map>(); + + allResults.forEach((results, runIndex) => { + if (results.length === 0) return; + + const testRun = testRuns[runIndex]; + if (!resultsByBody.has(testRun.bodyName)) { + resultsByBody.set(testRun.bodyName, []); + } + resultsByBody.get(testRun.bodyName)!.push({ results, runIndex }); + }); + + const isHighloadResult = (result: WalletTestResult) => result.walletName === highloadWalletName; + + const formatRealTime = (result: WalletTestResult): string => { + const seconds = isHighloadResult(result) + ? requestTimings.realSeconds + : result.requests * requestTimings.realSeconds; + return formatSeconds(seconds); + }; + + const formatTheoreticalTime = (result: WalletTestResult): string => { + const seconds = isHighloadResult(result) + ? requestTimings.theoreticalSeconds + : result.requests * requestTimings.theoreticalSeconds; + return formatSeconds(seconds); + }; + + const createColumns = ( + gasPerMsgValues: bigint[], + feePerMsgValues: bigint[], + minGasPerMsg: bigint, + minFeePerMsg: bigint, + ) => { + const columnAccessors: Record< + string, + { + markdownAccessor: (result: WalletTestResult, index: number) => string; + consoleAccessor: (result: WalletTestResult, index: number) => string | number; + } + > = { + walletVersion: { + markdownAccessor: (result, idx) => { + const gasPerMsg = gasPerMsgValues[idx]; + const feePerMsg = feePerMsgValues[idx]; + const isGasBest = gasPerMsg === minGasPerMsg; + const isFeeBest = feePerMsg === minFeePerMsg; + const isBest = isGasBest || isFeeBest; + return isBest ? `**${result.walletName}** ${isFeeBest ? '✅' : ''}` : result.walletName; + }, + consoleAccessor: (result) => result.walletName, + }, + gasDelta: { + markdownAccessor: (_result, idx) => + formatPercentDiffMarkdown(gasPerMsgValues[idx], minGasPerMsg), + consoleAccessor: (_result, idx) => + formatPercentDiffPlain(gasPerMsgValues[idx], minGasPerMsg), + }, + feeDelta: { + markdownAccessor: (_result, idx) => + formatPercentDiffMarkdown(feePerMsgValues[idx], minFeePerMsg), + consoleAccessor: (_result, idx) => + formatPercentDiffPlain(feePerMsgValues[idx], minFeePerMsg), + }, + requests: { + markdownAccessor: (result) => numberFormatter.format(result.requests), + consoleAccessor: (result) => result.requests, + }, + totalGas: { + markdownAccessor: (result) => numberFormatter.format(Number(result.totalGas)), + consoleAccessor: (result) => Number(result.totalGas), + }, + gasPerMsg: { + markdownAccessor: (_result, idx) => numberFormatter.format(Number(gasPerMsgValues[idx])), + consoleAccessor: (_result, idx) => Number(gasPerMsgValues[idx]), + }, + totalFee: { + markdownAccessor: (result) => tonFormatter.format(toCoins(result.totalFee)), + consoleAccessor: (result) => toCoins(result.totalFee), + }, + feePerMsg: { + markdownAccessor: (_result, idx) => tonFormatter.format(toCoins(feePerMsgValues[idx])), + consoleAccessor: (_result, idx) => toCoins(feePerMsgValues[idx]), + }, + realTime: { + markdownAccessor: (result) => formatRealTime(result), + consoleAccessor: (result) => formatRealTime(result), + }, + theoryTime: { + markdownAccessor: (result) => formatTheoreticalTime(result), + consoleAccessor: (result) => formatTheoreticalTime(result), + }, + }; + + return columnOrder + .filter((col) => col.enabled) + .map((col) => ({ + header: col.header, + alignment: col.alignment, + markdownAccessor: columnAccessors[col.key].markdownAccessor, + consoleAccessor: columnAccessors[col.key].consoleAccessor, + })); + }; + + resultsByBody.forEach((runsData, bodyName) => { + markdownLines.push(`## ${bodyName}`, ''); + + runsData.forEach(({ results, runIndex }) => { + const testRun = testRuns[runIndex]; + markdownLines.push(`### ${testRun.messageCount} Messages`, ''); + + const mainResults = results.filter((r) => r.walletName !== preprocessedWalletName); + const preprocessedResults = results.filter((r) => r.walletName === preprocessedWalletName); + + const mainGasPerMsgValues = mainResults.map((r) => r.totalGas / BigInt(r.messageCount)); + const mainFeePerMsgValues = mainResults.map((r) => r.totalFee / BigInt(r.messageCount)); + const minGasPerMsg = + mainGasPerMsgValues.length > 0 + ? mainGasPerMsgValues.reduce( + (min, val) => (val < min ? val : min), + mainGasPerMsgValues[0], + ) + : 0n; + const minFeePerMsg = + mainFeePerMsgValues.length > 0 + ? mainFeePerMsgValues.reduce( + (min, val) => (val < min ? val : min), + mainFeePerMsgValues[0], + ) + : 0n; + + let mainColumns: ReturnType | null = null; + let mainAllRowCells: string[][] = []; + let preprocessedColumns: ReturnType | null = null; + let preprocessedAllRowCells: string[][] = []; + + if (mainResults.length > 0) { + mainColumns = createColumns( + mainGasPerMsgValues, + mainFeePerMsgValues, + minGasPerMsg, + minFeePerMsg, + ); + mainAllRowCells = mainResults.map((result, idx) => + mainColumns!.map((column) => column.markdownAccessor(result, idx)), + ); + } + + if (preprocessedResults.length > 0) { + const preprocessedGasPerMsgValues = preprocessedResults.map( + (r) => r.totalGas / BigInt(r.messageCount), + ); + const preprocessedFeePerMsgValues = preprocessedResults.map( + (r) => r.totalFee / BigInt(r.messageCount), + ); + preprocessedColumns = createColumns( + preprocessedGasPerMsgValues, + preprocessedFeePerMsgValues, + minGasPerMsg, + minFeePerMsg, + ); + preprocessedAllRowCells = preprocessedResults.map((result, idx) => + preprocessedColumns!.map((column) => column.markdownAccessor(result, idx)), + ); + } + + const columns = mainColumns || preprocessedColumns; + if (!columns) return; + + const columnIndices = { + totalFee: columns.findIndex((c) => c.header === 'Total Fee (TON)'), + feePerMsg: columns.findIndex((c) => c.header === 'Fee/Msg (TON)'), + }; + + if (columnIndices.totalFee >= 0) { + const allTotalFeeValues = [ + ...mainAllRowCells.map((row) => row[columnIndices.totalFee]), + ...preprocessedAllRowCells.map((row) => row[columnIndices.totalFee]), + ]; + const trimmedTotalFee = trimCommonTrailingZeros(allTotalFeeValues); + + mainAllRowCells.forEach((row, idx) => { + row[columnIndices.totalFee] = trimmedTotalFee[idx]; + }); + preprocessedAllRowCells.forEach((row, idx) => { + row[columnIndices.totalFee] = trimmedTotalFee[mainAllRowCells.length + idx]; + }); + } + + if (columnIndices.feePerMsg >= 0) { + const allFeePerMsgValues = [ + ...mainAllRowCells.map((row) => row[columnIndices.feePerMsg]), + ...preprocessedAllRowCells.map((row) => row[columnIndices.feePerMsg]), + ]; + const trimmedFeePerMsg = trimCommonTrailingZeros(allFeePerMsgValues); + + mainAllRowCells.forEach((row, idx) => { + row[columnIndices.feePerMsg] = trimmedFeePerMsg[idx]; + }); + preprocessedAllRowCells.forEach((row, idx) => { + row[columnIndices.feePerMsg] = trimmedFeePerMsg[mainAllRowCells.length + idx]; + }); + } + + if (mainResults.length > 0 && mainColumns) { + const headerRow = `| ${mainColumns.map((column) => column.header).join(' | ')} |`; + const separatorRow = `| ${mainColumns.map((column) => getAlignmentMarker(column.alignment)).join(' | ')} |`; + markdownLines.push(headerRow, separatorRow); + + mainAllRowCells.forEach((rowCells) => { + markdownLines.push(`| ${rowCells.join(' | ')} |`); + }); + + const consoleRows = mainResults.map((result, idx) => { + const row: Record = {}; + mainColumns.forEach((column) => { + row[column.header] = column.consoleAccessor(result, idx); + }); + return row; + }); + + console.log(`\n${testRun.bodyName} - ${testRun.messageCount} Messages`); + console.table(consoleRows); + + markdownLines.push(''); + } + + if (preprocessedResults.length > 0 && preprocessedColumns) { + markdownLines.push('**Preprocessed Wallet V2**', ''); + + const headerRow = `| ${preprocessedColumns.map((column) => column.header).join(' | ')} |`; + const separatorRow = `| ${preprocessedColumns.map((column) => getAlignmentMarker(column.alignment)).join(' | ')} |`; + markdownLines.push(headerRow, separatorRow); + + preprocessedAllRowCells.forEach((rowCells) => { + markdownLines.push(`| ${rowCells.join(' | ')} |`); + }); + + const consoleRows = preprocessedResults.map((result, idx) => { + const row: Record = {}; + preprocessedColumns.forEach((column) => { + row[column.header] = column.consoleAccessor(result, idx); + }); + return row; + }); + + console.log(`\nPreprocessed Wallet V2:`); + console.table(consoleRows); + + markdownLines.push(''); + } + }); + }); + + const outputDir = path.resolve(outputDirectory, 'results'); + const outputFile = path.join(outputDir, 'wallet-fee-comparison.md'); + mkdirSync(outputDir, { recursive: true }); + writeFileSync(outputFile, markdownLines.join('\n'), { encoding: 'utf-8' }); + console.log(`Markdown report saved to ${outputFile}`); +} diff --git a/standard/wallets/comparison/tests/results/wallet-fee-comparison.md b/standard/wallets/comparison/tests/results/wallet-fee-comparison.md new file mode 100644 index 0000000..f814a98 --- /dev/null +++ b/standard/wallets/comparison/tests/results/wallet-fee-comparison.md @@ -0,0 +1,223 @@ +# Wallet Fee Comparison Results + +### Sending TONs + +#### 1 Messages + +| Wallet Version | Gas delta % | Fee delta % | Requests | Total Gas | Gas/Msg | Total Fee (TON) | Fee/Msg (TON) | Real Time (s) | Theory Time (s) | +| :----------------: | ----------: | ----------: | -------: | --------: | ------: | --------------: | ------------: | ------------: | --------------: | +| **Wallet V2R1** ✅ | **Best** | **Best** | 1 | 2,769 | 2,769 | 0.0017108 | 0.0017108 | 13s | 4s | +| Wallet V2R2 | +2.78% | +1.80% | 1 | 2,846 | 2,846 | 0.0017416 | 0.0017416 | 13s | 4s | +| Wallet V3R1 | +5.34% | +3.46% | 1 | 2,917 | 2,917 | 0.0017700 | 0.0017700 | 13s | 4s | +| Wallet V3R2 | +8.12% | +5.26% | 1 | 2,994 | 2,994 | 0.0018008 | 0.0018008 | 13s | 4s | +| Wallet V4R2 | +19.46% | +12.60% | 1 | 3,308 | 3,308 | 0.0019264 | 0.0019264 | 13s | 4s | +| Wallet V5R1 | +78.36% | +56.34% | 1 | 4,939 | 4,939 | 0.0026748 | 0.0026748 | 13s | 4s | +| Highload Wallet V3 | +187.32% | +187.14% | 1 | 7,956 | 7,956 | 0.0049124 | 0.0049124 | 13s | 4s | + +**Preprocessed Wallet V2** + +| Wallet Version | Gas delta % | Fee delta % | Requests | Total Gas | Gas/Msg | Total Fee (TON) | Fee/Msg (TON) | Real Time (s) | Theory Time (s) | +| :--------------------: | ----------: | ----------: | -------: | --------: | ------: | --------------: | ------------: | ------------: | --------------: | +| Preprocessed Wallet V2 | -44.49% | -18.98% | 1 | 1,537 | 1,537 | 0.0013860 | 0.0013860 | 13s | 4s | + +#### 4 Messages + +| Wallet Version | Gas delta % | Fee delta % | Requests | Total Gas | Gas/Msg | Total Fee (TON) | Fee/Msg (TON) | Real Time (s) | Theory Time (s) | +| :----------------: | ----------: | ----------: | -------: | --------: | ------: | --------------: | ------------: | ------------: | --------------: | +| **Wallet V2R1** ✅ | **Best** | **Best** | 1 | 4,695 | 1,173 | 0.0030908 | 0.0007727 | 13s | 4s | +| Wallet V2R2 | +1.70% | +0.99% | 1 | 4,772 | 1,193 | 0.0031216 | 0.0007804 | 13s | 4s | +| Wallet V3R1 | +3.15% | +1.91% | 1 | 4,843 | 1,210 | 0.0031500 | 0.0007875 | 13s | 4s | +| Wallet V3R2 | +4.85% | +2.91% | 1 | 4,920 | 1,230 | 0.0031808 | 0.0007952 | 13s | 4s | +| Wallet V4R2 | +11.50% | +6.97% | 1 | 5,234 | 1,308 | 0.0033064 | 0.0008266 | 13s | 4s | +| Wallet V5R1 | +51.06% | +39.53% | 1 | 7,090 | 1,772 | 0.0043128 | 0.0010782 | 13s | 4s | +| Highload Wallet V3 | +69.56% | +109.25% | 1 | 7,956 | 1,989 | 0.0064676 | 0.0016169 | 13s | 4s | + +**Preprocessed Wallet V2** + +| Wallet Version | Gas delta % | Fee delta % | Requests | Total Gas | Gas/Msg | Total Fee (TON) | Fee/Msg (TON) | Real Time (s) | Theory Time (s) | +| :--------------------: | ----------: | ----------: | -------: | --------: | ------: | --------------: | ------------: | ------------: | --------------: | +| Preprocessed Wallet V2 | -67.26% | -29.99% | 1 | 1,537 | 384 | 0.0021636 | 0.0005409 | 13s | 4s | + +#### 200 Messages + +| Wallet Version | Gas delta % | Fee delta % | Requests | Total Gas | Gas/Msg | Total Fee (TON) | Fee/Msg (TON) | Real Time (s) | Theory Time (s) | +| :-----------------------: | ----------: | ----------: | -------: | --------: | ------: | --------------: | ------------: | ------------: | --------------: | +| Wallet V2R1 | +2907.69% | +42.99% | 50 | 234,750 | 1,173 | 0.1545400 | 0.000772700 | 10m 50s | 3m 20s | +| Wallet V2R2 | +2958.97% | +44.41% | 50 | 238,600 | 1,193 | 0.1560800 | 0.000780400 | 10m 50s | 3m 20s | +| Wallet V3R1 | +3002.56% | +45.73% | 50 | 242,150 | 1,210 | 0.1575000 | 0.000787500 | 10m 50s | 3m 20s | +| Wallet V3R2 | +3053.84% | +47.15% | 50 | 246,000 | 1,230 | 0.1590400 | 0.000795200 | 10m 50s | 3m 20s | +| Wallet V4R2 | +3253.84% | +52.96% | 50 | 261,700 | 1,308 | 0.1653200 | 0.000826600 | 10m 50s | 3m 20s | +| Wallet V5R1 | +1792.30% | +3.01% | 1 | 147,622 | 738 | 0.1113288 | 0.000556644 | 13s | 4s | +| **Highload Wallet V3** ✅ | **Best** | **Best** | 1 | 7,956 | 39 | 0.1080740 | 0.000540370 | 13s | 4s | + +**Preprocessed Wallet V2** + +| Wallet Version | Gas delta % | Fee delta % | Requests | Total Gas | Gas/Msg | Total Fee (TON) | Fee/Msg (TON) | Real Time (s) | Theory Time (s) | +| :--------------------: | ----------: | ----------: | -------: | --------: | ------: | --------------: | ------------: | ------------: | --------------: | +| Preprocessed Wallet V2 | -82.05% | -50.99% | 1 | 1,537 | 7 | 0.0529668 | 0.000264834 | 13s | 4s | + +#### 1000 Messages + +| Wallet Version | Gas delta % | Fee delta % | Requests | Total Gas | Gas/Msg | Total Fee (TON) | Fee/Msg (TON) | Real Time (s) | Theory Time (s) | +| :-----------------------: | ----------: | ----------: | -------: | --------: | ------: | --------------: | ------------: | ------------: | --------------: | +| Wallet V2R1 | +3683.87% | +44.18% | 250 | 1,173,750 | 1,173 | 0.7727000 | 0.000772700 | 54m 10s | 16m 40s | +| Wallet V2R2 | +3748.38% | +45.61% | 250 | 1,193,000 | 1,193 | 0.7804000 | 0.000780400 | 54m 10s | 16m 40s | +| Wallet V3R1 | +3803.22% | +46.94% | 250 | 1,210,750 | 1,210 | 0.7875000 | 0.000787500 | 54m 10s | 16m 40s | +| Wallet V3R2 | +3867.74% | +48.37% | 250 | 1,230,000 | 1,230 | 0.7952000 | 0.000795200 | 54m 10s | 16m 40s | +| Wallet V4R2 | +4119.35% | +54.23% | 250 | 1,308,500 | 1,308 | 0.8266000 | 0.000826600 | 54m 10s | 16m 40s | +| Wallet V5R1 | +2264.51% | +3.46% | 4 | 733,888 | 733 | 0.5545152 | 0.000554515 | 52s | 16s | +| **Highload Wallet V3** ✅ | **Best** | **Best** | 4 | 31,689 | 31 | 0.5359220 | 0.000535922 | 13s | 4s | + +**Preprocessed Wallet V2** + +| Wallet Version | Gas delta % | Fee delta % | Requests | Total Gas | Gas/Msg | Total Fee (TON) | Fee/Msg (TON) | Real Time (s) | Theory Time (s) | +| :--------------------: | ----------: | ----------: | -------: | --------: | ------: | --------------: | ------------: | ------------: | --------------: | +| Preprocessed Wallet V2 | -80.64% | -50.79% | 4 | 6,148 | 6 | 0.2637072 | 0.000263707 | 52s | 16s | + +### Sending Comment + +#### 1 Messages + +| Wallet Version | Gas delta % | Fee delta % | Requests | Total Gas | Gas/Msg | Total Fee (TON) | Fee/Msg (TON) | Real Time (s) | Theory Time (s) | +| :----------------: | ----------: | ----------: | -------: | --------: | ------: | --------------: | ------------: | ------------: | --------------: | +| **Wallet V2R1** ✅ | **Best** | **Best** | 1 | 2,769 | 2,769 | 0.0017620 | 0.0017620 | 13s | 4s | +| Wallet V2R2 | +2.78% | +1.74% | 1 | 2,846 | 2,846 | 0.0017928 | 0.0017928 | 13s | 4s | +| Wallet V3R1 | +5.34% | +3.35% | 1 | 2,917 | 2,917 | 0.0018212 | 0.0018212 | 13s | 4s | +| Wallet V3R2 | +8.12% | +5.10% | 1 | 2,994 | 2,994 | 0.0018520 | 0.0018520 | 13s | 4s | +| Wallet V4R2 | +19.46% | +12.23% | 1 | 3,308 | 3,308 | 0.0019776 | 0.0019776 | 13s | 4s | +| Wallet V5R1 | +78.36% | +54.71% | 1 | 4,939 | 4,939 | 0.0027260 | 0.0027260 | 13s | 4s | +| Highload Wallet V3 | +187.32% | +184.60% | 1 | 7,956 | 7,956 | 0.0050148 | 0.0050148 | 13s | 4s | + +**Preprocessed Wallet V2** + +| Wallet Version | Gas delta % | Fee delta % | Requests | Total Gas | Gas/Msg | Total Fee (TON) | Fee/Msg (TON) | Real Time (s) | Theory Time (s) | +| :--------------------: | ----------: | ----------: | -------: | --------: | ------: | --------------: | ------------: | ------------: | --------------: | +| Preprocessed Wallet V2 | -44.49% | -18.43% | 1 | 1,537 | 1,537 | 0.0014372 | 0.0014372 | 13s | 4s | + +#### 4 Messages + +| Wallet Version | Gas delta % | Fee delta % | Requests | Total Gas | Gas/Msg | Total Fee (TON) | Fee/Msg (TON) | Real Time (s) | Theory Time (s) | +| :----------------: | ----------: | ----------: | -------: | --------: | ------: | --------------: | ------------: | ------------: | --------------: | +| **Wallet V2R1** ✅ | **Best** | **Best** | 1 | 4,695 | 1,173 | 0.0032956 | 0.0008239 | 13s | 4s | +| Wallet V2R2 | +1.70% | +0.93% | 1 | 4,772 | 1,193 | 0.0033264 | 0.0008316 | 13s | 4s | +| Wallet V3R1 | +3.15% | +1.79% | 1 | 4,843 | 1,210 | 0.0033548 | 0.0008387 | 13s | 4s | +| Wallet V3R2 | +4.85% | +2.73% | 1 | 4,920 | 1,230 | 0.0033856 | 0.0008464 | 13s | 4s | +| Wallet V4R2 | +11.50% | +6.54% | 1 | 5,234 | 1,308 | 0.0035112 | 0.0008778 | 13s | 4s | +| Wallet V5R1 | +51.06% | +37.07% | 1 | 7,090 | 1,772 | 0.0045176 | 0.0011294 | 13s | 4s | +| Highload Wallet V3 | +69.56% | +108.67% | 1 | 7,956 | 1,989 | 0.0068772 | 0.0017193 | 13s | 4s | + +**Preprocessed Wallet V2** + +| Wallet Version | Gas delta % | Fee delta % | Requests | Total Gas | Gas/Msg | Total Fee (TON) | Fee/Msg (TON) | Real Time (s) | Theory Time (s) | +| :--------------------: | ----------: | ----------: | -------: | --------: | ------: | --------------: | ------------: | ------------: | --------------: | +| Preprocessed Wallet V2 | -67.26% | -28.13% | 1 | 1,537 | 384 | 0.0023684 | 0.0005921 | 13s | 4s | + +#### 200 Messages + +| Wallet Version | Gas delta % | Fee delta % | Requests | Total Gas | Gas/Msg | Total Fee (TON) | Fee/Msg (TON) | Real Time (s) | Theory Time (s) | +| :--------------------: | ----------: | ----------: | -------: | --------: | ------: | --------------: | ------------: | ------------: | --------------: | +| Wallet V2R1 | +2907.69% | +35.54% | 50 | 234,750 | 1,173 | 0.1647800 | 0.000823900 | 10m 50s | 3m 20s | +| Wallet V2R2 | +2958.97% | +36.81% | 50 | 238,600 | 1,193 | 0.1663200 | 0.000831600 | 10m 50s | 3m 20s | +| Wallet V3R1 | +3002.56% | +37.97% | 50 | 242,150 | 1,210 | 0.1677400 | 0.000838700 | 10m 50s | 3m 20s | +| Wallet V3R2 | +3053.84% | +39.24% | 50 | 246,000 | 1,230 | 0.1692800 | 0.000846400 | 10m 50s | 3m 20s | +| Wallet V4R2 | +3253.84% | +44.41% | 50 | 261,700 | 1,308 | 0.1755600 | 0.000877800 | 10m 50s | 3m 20s | +| **Wallet V5R1** ✅ | +1792.30% | **Best** | 1 | 147,622 | 738 | 0.1215688 | 0.000607844 | 13s | 4s | +| **Highload Wallet V3** | **Best** | +5.74% | 1 | 7,956 | 39 | 0.1285540 | 0.000642770 | 13s | 4s | + +**Preprocessed Wallet V2** + +| Wallet Version | Gas delta % | Fee delta % | Requests | Total Gas | Gas/Msg | Total Fee (TON) | Fee/Msg (TON) | Real Time (s) | Theory Time (s) | +| :--------------------: | ----------: | ----------: | -------: | --------: | ------: | --------------: | ------------: | ------------: | --------------: | +| Preprocessed Wallet V2 | -82.05% | -48.00% | 1 | 1,537 | 7 | 0.0632068 | 0.000316034 | 13s | 4s | + +#### 1000 Messages + +| Wallet Version | Gas delta % | Fee delta % | Requests | Total Gas | Gas/Msg | Total Fee (TON) | Fee/Msg (TON) | Real Time (s) | Theory Time (s) | +| :--------------------: | ----------: | ----------: | -------: | --------: | ------: | --------------: | ------------: | ------------: | --------------: | +| Wallet V2R1 | +3683.87% | +36.02% | 250 | 1,173,750 | 1,173 | 0.8239000 | 0.000823900 | 54m 10s | 16m 40s | +| Wallet V2R2 | +3748.38% | +37.29% | 250 | 1,193,000 | 1,193 | 0.8316000 | 0.000831600 | 54m 10s | 16m 40s | +| Wallet V3R1 | +3803.22% | +38.46% | 250 | 1,210,750 | 1,210 | 0.8387000 | 0.000838700 | 54m 10s | 16m 40s | +| Wallet V3R2 | +3867.74% | +39.73% | 250 | 1,230,000 | 1,230 | 0.8464000 | 0.000846400 | 54m 10s | 16m 40s | +| Wallet V4R2 | +4119.35% | +44.91% | 250 | 1,308,500 | 1,308 | 0.8778000 | 0.000877800 | 54m 10s | 16m 40s | +| **Wallet V5R1** ✅ | +2264.51% | **Best** | 4 | 733,888 | 733 | 0.6057152 | 0.000605715 | 52s | 16s | +| **Highload Wallet V3** | **Best** | +5.38% | 4 | 31,689 | 31 | 0.6383220 | 0.000638322 | 13s | 4s | + +**Preprocessed Wallet V2** + +| Wallet Version | Gas delta % | Fee delta % | Requests | Total Gas | Gas/Msg | Total Fee (TON) | Fee/Msg (TON) | Real Time (s) | Theory Time (s) | +| :--------------------: | ----------: | ----------: | -------: | --------: | ------: | --------------: | ------------: | ------------: | --------------: | +| Preprocessed Wallet V2 | -80.64% | -48.01% | 4 | 6,148 | 6 | 0.3149072 | 0.000314907 | 52s | 16s | + +### Sending Jettons + +#### 1 Messages + +| Wallet Version | Gas delta % | Fee delta % | Requests | Total Gas | Gas/Msg | Total Fee (TON) | Fee/Msg (TON) | Real Time (s) | Theory Time (s) | +| :----------------: | ----------: | ----------: | -------: | --------: | ------: | --------------: | ------------: | ------------: | --------------: | +| **Wallet V2R1** ✅ | **Best** | **Best** | 1 | 2,769 | 2,769 | 0.0021012 | 0.0021012 | 13s | 4s | +| Wallet V2R2 | +2.78% | +1.46% | 1 | 2,846 | 2,846 | 0.0021320 | 0.0021320 | 13s | 4s | +| Wallet V3R1 | +5.34% | +2.81% | 1 | 2,917 | 2,917 | 0.0021604 | 0.0021604 | 13s | 4s | +| Wallet V3R2 | +8.12% | +4.28% | 1 | 2,994 | 2,994 | 0.0021912 | 0.0021912 | 13s | 4s | +| Wallet V4R2 | +19.46% | +10.26% | 1 | 3,308 | 3,308 | 0.0023168 | 0.0023168 | 13s | 4s | +| Wallet V5R1 | +78.36% | +45.87% | 1 | 4,939 | 4,939 | 0.0030652 | 0.0030652 | 13s | 4s | +| Highload Wallet V3 | +187.32% | +170.94% | 1 | 7,956 | 7,956 | 0.0056932 | 0.0056932 | 13s | 4s | + +**Preprocessed Wallet V2** + +| Wallet Version | Gas delta % | Fee delta % | Requests | Total Gas | Gas/Msg | Total Fee (TON) | Fee/Msg (TON) | Real Time (s) | Theory Time (s) | +| :--------------------: | ----------: | ----------: | -------: | --------: | ------: | --------------: | ------------: | ------------: | --------------: | +| Preprocessed Wallet V2 | -44.49% | -15.45% | 1 | 1,537 | 1,537 | 0.0017764 | 0.0017764 | 13s | 4s | + +#### 4 Messages + +| Wallet Version | Gas delta % | Fee delta % | Requests | Total Gas | Gas/Msg | Total Fee (TON) | Fee/Msg (TON) | Real Time (s) | Theory Time (s) | +| :----------------: | ----------: | ----------: | -------: | --------: | ------: | --------------: | ------------: | ------------: | --------------: | +| **Wallet V2R1** ✅ | **Best** | **Best** | 1 | 4,695 | 1,173 | 0.0046524 | 0.0011631 | 13s | 4s | +| Wallet V2R2 | +1.70% | +0.66% | 1 | 4,772 | 1,193 | 0.0046832 | 0.0011708 | 13s | 4s | +| Wallet V3R1 | +3.15% | +1.27% | 1 | 4,843 | 1,210 | 0.0047116 | 0.0011779 | 13s | 4s | +| Wallet V3R2 | +4.85% | +1.93% | 1 | 4,920 | 1,230 | 0.0047424 | 0.0011856 | 13s | 4s | +| Wallet V4R2 | +11.50% | +4.63% | 1 | 5,234 | 1,308 | 0.0048680 | 0.0012170 | 13s | 4s | +| Wallet V5R1 | +51.06% | +26.26% | 1 | 7,090 | 1,772 | 0.0058744 | 0.0014686 | 13s | 4s | +| Highload Wallet V3 | +69.56% | +106.14% | 1 | 7,956 | 1,989 | 0.0095908 | 0.0023977 | 13s | 4s | + +**Preprocessed Wallet V2** + +| Wallet Version | Gas delta % | Fee delta % | Requests | Total Gas | Gas/Msg | Total Fee (TON) | Fee/Msg (TON) | Real Time (s) | Theory Time (s) | +| :--------------------: | ----------: | ----------: | -------: | --------: | ------: | --------------: | ------------: | ------------: | --------------: | +| Preprocessed Wallet V2 | -67.26% | -19.92% | 1 | 1,537 | 384 | 0.0037252 | 0.0009313 | 13s | 4s | + +#### 200 Messages + +| Wallet Version | Gas delta % | Fee delta % | Requests | Total Gas | Gas/Msg | Total Fee (TON) | Fee/Msg (TON) | Real Time (s) | Theory Time (s) | +| :--------------------: | ----------: | ----------: | -------: | --------: | ------: | --------------: | ------------: | ------------: | --------------: | +| Wallet V2R1 | +2907.69% | +22.81% | 50 | 234,750 | 1,173 | 0.2326200 | 0.001163100 | 10m 50s | 3m 20s | +| Wallet V2R2 | +2958.97% | +23.62% | 50 | 238,600 | 1,193 | 0.2341600 | 0.001170800 | 10m 50s | 3m 20s | +| Wallet V3R1 | +3002.56% | +24.37% | 50 | 242,150 | 1,210 | 0.2355800 | 0.001177900 | 10m 50s | 3m 20s | +| Wallet V3R2 | +3053.84% | +25.18% | 50 | 246,000 | 1,230 | 0.2371200 | 0.001185600 | 10m 50s | 3m 20s | +| Wallet V4R2 | +3253.84% | +28.50% | 50 | 261,700 | 1,308 | 0.2434000 | 0.001217000 | 10m 50s | 3m 20s | +| **Wallet V5R1** ✅ | +1792.30% | **Best** | 1 | 147,622 | 738 | 0.1894088 | 0.000947044 | 13s | 4s | +| **Highload Wallet V3** | **Best** | +39.50% | 1 | 7,956 | 39 | 0.2642340 | 0.001321170 | 13s | 4s | + +**Preprocessed Wallet V2** + +| Wallet Version | Gas delta % | Fee delta % | Requests | Total Gas | Gas/Msg | Total Fee (TON) | Fee/Msg (TON) | Real Time (s) | Theory Time (s) | +| :--------------------: | ----------: | ----------: | -------: | --------: | ------: | --------------: | ------------: | ------------: | --------------: | +| Preprocessed Wallet V2 | -82.05% | -30.81% | 1 | 1,537 | 7 | 0.1310468 | 0.000655234 | 13s | 4s | + +#### 1000 Messages + +| Wallet Version | Gas delta % | Fee delta % | Requests | Total Gas | Gas/Msg | Total Fee (TON) | Fee/Msg (TON) | Real Time (s) | Theory Time (s) | +| :--------------------: | ----------: | ----------: | -------: | --------: | ------: | --------------: | ------------: | ------------: | --------------: | +| Wallet V2R1 | +3683.87% | +23.09% | 250 | 1,173,750 | 1,173 | 1.1631000 | 0.001163100 | 54m 10s | 16m 40s | +| Wallet V2R2 | +3748.38% | +23.90% | 250 | 1,193,000 | 1,193 | 1.1708000 | 0.001170800 | 54m 10s | 16m 40s | +| Wallet V3R1 | +3803.22% | +24.65% | 250 | 1,210,750 | 1,210 | 1.1779000 | 0.001177900 | 54m 10s | 16m 40s | +| Wallet V3R2 | +3867.74% | +25.47% | 250 | 1,230,000 | 1,230 | 1.1856000 | 0.001185600 | 54m 10s | 16m 40s | +| Wallet V4R2 | +4119.35% | +28.79% | 250 | 1,308,500 | 1,308 | 1.2170000 | 0.001217000 | 54m 10s | 16m 40s | +| **Wallet V5R1** ✅ | +2264.51% | **Best** | 4 | 733,888 | 733 | 0.9449152 | 0.000944915 | 52s | 16s | +| **Highload Wallet V3** | **Best** | +39.34% | 4 | 31,689 | 31 | 1.3167220 | 0.001316722 | 13s | 4s | + +**Preprocessed Wallet V2** + +| Wallet Version | Gas delta % | Fee delta % | Requests | Total Gas | Gas/Msg | Total Fee (TON) | Fee/Msg (TON) | Real Time (s) | Theory Time (s) | +| :--------------------: | ----------: | ----------: | -------: | --------: | ------: | --------------: | ------------: | ------------: | --------------: | +| Preprocessed Wallet V2 | -80.64% | -30.77% | 4 | 6,148 | 6 | 0.6541072 | 0.000654107 | 52s | 16s | diff --git a/standard/wallets/comparison/tests/utils/fee-extraction.ts b/standard/wallets/comparison/tests/utils/fee-extraction.ts new file mode 100644 index 0000000..207fbcc --- /dev/null +++ b/standard/wallets/comparison/tests/utils/fee-extraction.ts @@ -0,0 +1,72 @@ +import { Blockchain } from '@ton/sandbox'; +import { beginCell, storeMessage } from '@ton/core'; +import { computeCellForwardFees, getMsgPrices } from './gas-utils'; + +/** + * Transaction fee components extracted from a transaction. + * + * Note: According to TON documentation, msg_fwd_fees already includes the action fee. + * For internal messages: msg_fwd_fees = action_fee + fwd_fee + * where action_fee ≈ msg_fwd_fees * first_frac / 2^16 + * + * Reference: https://docs.ton.org/develop/howto/fees-low-level#forward-fee + */ +export type TransactionFees = { + storage_fee: bigint; // Storage fees collected during storage phase + gas_fees: bigint; // Computation fees (gas) from compute phase + action_fees: bigint; // Action phase fees for sending messages + out_fwd_fees: bigint; // Total forward fees for outbound messages (includes action_fees) + import_fee: bigint; // Import fee for external-in messages (0 for internal) + in_fwd_fee: bigint; // Forward fee for inbound internal messages (0 for external) +}; + +/** + * Extracts fee components from a transaction. + * + * @param tx - Transaction object to analyze + * @param blockchain - Blockchain instance for config access (required for import_fee calculation) + * @returns TransactionFees object with detailed fee breakdown + */ +export function extractTransactionFees(tx: any, blockchain: Blockchain): TransactionFees { + const fees: TransactionFees = { + storage_fee: 0n, + gas_fees: 0n, + action_fees: 0n, + out_fwd_fees: 0n, + import_fee: 0n, + in_fwd_fee: 0n, + }; + + if (tx.description.type !== 'generic') { + return fees; + } + + // Storage fee + fees.storage_fee = (tx.description.storagePhase?.storageFeesCollected ?? 0n) as bigint; + + // Compute phase: gas fees + if (tx.description.computePhase.type === 'vm') { + fees.gas_fees = tx.description.computePhase.gasFees as bigint; + } + + // Action phase: fees for sending messages, setting code, etc. + fees.action_fees = (tx.description.actionPhase?.totalActionFees ?? 0n) as bigint; + + // Action phase: total forward fees for outbound messages + // Note: totalFwdFees includes action_fees (sender's share of msg_fwd_fees) + fees.out_fwd_fees = (tx.description.actionPhase?.totalFwdFees ?? 0n) as bigint; + + // Inbound message fees (depends on message type) + if (tx.inMessage?.info.type === 'external-in') { + // External messages: import fee + const msgPrices = getMsgPrices(blockchain.config, 0); + + const extMsgCell = beginCell().store(storeMessage(tx.inMessage)).endCell(); + fees.import_fee = computeCellForwardFees(msgPrices, extMsgCell); + } else if (tx.inMessage?.info.type === 'internal') { + // Internal messages: forward fee paid by sender + fees.in_fwd_fee = tx.inMessage.info.forwardFee as bigint; + } + + return fees; +} diff --git a/standard/wallets/comparison/tests/utils/gas-utils.ts b/standard/wallets/comparison/tests/utils/gas-utils.ts new file mode 100644 index 0000000..e189bfb --- /dev/null +++ b/standard/wallets/comparison/tests/utils/gas-utils.ts @@ -0,0 +1,379 @@ +import { + Cell, + Slice, + toNano, + beginCell, + Address, + Dictionary, + Message, + DictionaryValue, + Transaction, +} from '@ton/core'; + +export type GasPrices = { + flat_gas_limit: bigint; + flat_gas_price: bigint; + gas_price: bigint; +}; +export type StoragePrices = { + utime_sice: number; + bit_price_ps: bigint; + cell_price_ps: bigint; + mc_bit_price_ps: bigint; + mc_cell_price_ps: bigint; +}; + +export type MsgPrices = ReturnType; +export type FullFees = ReturnType; + +export class StorageStats { + bits: bigint; + cells: bigint; + + constructor(bits?: number | bigint, cells?: number | bigint) { + this.bits = bits !== undefined ? BigInt(bits) : 0n; + this.cells = cells !== undefined ? BigInt(cells) : 0n; + } + add(...stats: StorageStats[]) { + let cells = this.cells, + bits = this.bits; + for (let stat of stats) { + bits += stat.bits; + cells += stat.cells; + } + return new StorageStats(bits, cells); + } + sub(...stats: StorageStats[]) { + let cells = this.cells, + bits = this.bits; + for (let stat of stats) { + bits -= stat.bits; + cells -= stat.cells; + } + return new StorageStats(bits, cells); + } + addBits(bits: number | bigint) { + return new StorageStats(this.bits + BigInt(bits), this.cells); + } + subBits(bits: number | bigint) { + return new StorageStats(this.bits - BigInt(bits), this.cells); + } + addCells(cells: number | bigint) { + return new StorageStats(this.bits, this.cells + BigInt(cells)); + } + subCells(cells: number | bigint) { + return new StorageStats(this.bits, this.cells - BigInt(cells)); + } + + toString(): string { + return JSON.stringify({ + bits: this.bits.toString(), + cells: this.cells.toString(), + }); + } +} + +export function computedGeneric(transaction: T) { + if (transaction.description.type !== 'generic') + throw new Error('Expected generic transactionaction'); + if (transaction.description.computePhase.type !== 'vm') throw new Error('Compute phase expected'); + return transaction.description.computePhase; +} + +export function storageGeneric(transaction: T) { + if (transaction.description.type !== 'generic') + throw new Error('Expected generic transactionaction'); + const storagePhase = transaction.description.storagePhase; + if (storagePhase === null || storagePhase === undefined) + throw new Error('Storage phase expected'); + return storagePhase; +} +export function getFwdStats(transaction: T) { + if (transaction.description.type !== 'generic') throw new Error('Expected generic transaction'); + if (transaction.description.actionPhase == undefined) throw new Error('Action phase expected'); + const actionMsgSize = transaction.description.actionPhase.totalMessageSize; + return new StorageStats(actionMsgSize.bits, actionMsgSize.cells); +} + +function shr16ceil(src: bigint) { + let rem = src % BigInt(65536); + let res = src / 65536n; // >> BigInt(16); + if (rem != BigInt(0)) { + res += BigInt(1); + } + return res; +} + +export function collectCellStats( + cell: Cell, + visited: Array, + skipRoot: boolean = false, +): StorageStats { + let bits = skipRoot ? 0n : BigInt(cell.bits.length); + let cells = skipRoot ? 0n : 1n; + let hash = cell.hash().toString(); + if (visited.includes(hash)) { + // We should not account for current cell data if visited + return new StorageStats(); + } else { + visited.push(hash); + } + for (let ref of cell.refs) { + let r = collectCellStats(ref, visited); + cells += r.cells; + bits += r.bits; + } + return new StorageStats(bits, cells); +} + +export function getGasPrices(configRaw: Cell, workchain: 0 | -1): GasPrices { + const config = configRaw + .beginParse() + .loadDictDirect(Dictionary.Keys.Int(32), Dictionary.Values.Cell()); + + const ds = config.get(21 + workchain)!.beginParse(); + if (ds.loadUint(8) !== 0xd1) { + throw new Error('Invalid flat gas prices tag!'); + } + + const flat_gas_limit = ds.loadUintBig(64); + const flat_gas_price = ds.loadUintBig(64); + + if (ds.loadUint(8) !== 0xde) { + throw new Error('Invalid gas prices tag!'); + } + return { + flat_gas_limit, + flat_gas_price, + gas_price: ds.preloadUintBig(64), + }; +} + +export function setGasPrice(configRaw: Cell, prices: GasPrices, workchain: 0 | -1): Cell { + const config = configRaw + .beginParse() + .loadDictDirect(Dictionary.Keys.Int(32), Dictionary.Values.Cell()); + const idx = 21 + workchain; + const ds = config.get(idx)!; + const tail = ds.beginParse().skip(8 + 64 + 64 + 8 + 64); + + const newPrices = beginCell() + .storeUint(0xd1, 8) + .storeUint(prices.flat_gas_limit, 64) + .storeUint(prices.flat_gas_price, 64) + .storeUint(0xde, 8) + .storeUint(prices.gas_price, 64) + .storeSlice(tail) + .endCell(); + config.set(idx, newPrices); + + return beginCell().storeDictDirect(config).endCell(); +} + +export const storageValue: DictionaryValue = { + serialize: (src, builder) => { + builder + .storeUint(0xcc, 8) + .storeUint(src.utime_sice, 32) + .storeUint(src.bit_price_ps, 64) + .storeUint(src.cell_price_ps, 64) + .storeUint(src.mc_bit_price_ps, 64) + .storeUint(src.mc_cell_price_ps, 64); + }, + parse: (src) => { + return { + utime_sice: src.skip(8).loadUint(32), + bit_price_ps: src.loadUintBig(64), + cell_price_ps: src.loadUintBig(64), + mc_bit_price_ps: src.loadUintBig(64), + mc_cell_price_ps: src.loadUintBig(64), + }; + }, +}; + +export function getStoragePrices(configRaw: Cell) { + const config = configRaw + .beginParse() + .loadDictDirect(Dictionary.Keys.Int(32), Dictionary.Values.Cell()); + const storageData = Dictionary.loadDirect( + Dictionary.Keys.Uint(32), + storageValue, + config.get(18)!, + ); + const values = storageData.values(); + + return values[values.length - 1]; +} +export function calcStorageFee(prices: StoragePrices, stats: StorageStats, duration: bigint) { + return shr16ceil( + (stats.bits * prices.bit_price_ps + stats.cells * prices.cell_price_ps) * duration, + ); +} +export function setStoragePrices(configRaw: Cell, prices: StoragePrices) { + const config = configRaw + .beginParse() + .loadDictDirect(Dictionary.Keys.Int(32), Dictionary.Values.Cell()); + const storageData = Dictionary.loadDirect( + Dictionary.Keys.Uint(32), + storageValue, + config.get(18)!, + ); + storageData.set(storageData.values().length - 1, prices); + config.set(18, beginCell().storeDictDirect(storageData).endCell()); + return beginCell().storeDictDirect(config).endCell(); +} + +export function computeGasFee(prices: GasPrices, gas: bigint): bigint { + if (gas <= prices.flat_gas_limit) { + return prices.flat_gas_price; + } + return prices.flat_gas_price + (prices.gas_price * (gas - prices.flat_gas_limit)) / 65536n; +} + +export function computeDefaultForwardFee(msgPrices: MsgPrices) { + return msgPrices.lumpPrice - ((msgPrices.lumpPrice * msgPrices.firstFrac) >> BigInt(16)); +} + +export function computeCellForwardFees(msgPrices: MsgPrices, msg: Cell) { + let storageStats = collectCellStats(msg, [], true); + return computeFwdFees(msgPrices, storageStats.cells, storageStats.bits); +} +export function computeMessageForwardFees(msgPrices: MsgPrices, msg: Message) { + // let msg = loadMessageRelaxed(cell.beginParse()); + let storageStats = new StorageStats(); + + if (msg.info.type !== 'internal') { + throw Error('Helper intended for internal messages'); + } + const defaultFwd = computeDefaultForwardFee(msgPrices); + // If message forward fee matches default than msg cell is flat + if (msg.info.forwardFee == defaultFwd) { + return { + fees: { + total: msgPrices.lumpPrice, + res: msgPrices.lumpPrice - defaultFwd, + remaining: defaultFwd, + }, + stats: storageStats, + }; + } + let visited: Array = []; + // Init + if (msg.init) { + let addBits = 5n; // Minimal additional bits + let refCount = 0; + if (msg.init.splitDepth) { + addBits += 5n; + } + if (msg.init.libraries) { + refCount++; + storageStats = storageStats.add( + collectCellStats(beginCell().storeDictDirect(msg.init.libraries).endCell(), visited, true), + ); + } + if (msg.init.code) { + refCount++; + storageStats = storageStats.add(collectCellStats(msg.init.code, visited)); + } + if (msg.init.data) { + refCount++; + storageStats = storageStats.add(collectCellStats(msg.init.data, visited)); + } + if (refCount >= 2) { + //https://github.com/ton-blockchain/ton/blob/51baec48a02e5ba0106b0565410d2c2fd4665157/crypto/block/transaction.cpp#L2079 + storageStats.cells++; + storageStats.bits += addBits; + } + } + const lumpBits = BigInt(msg.body.bits.length); + const bodyStats = collectCellStats(msg.body, visited, true); + storageStats = storageStats.add(bodyStats); + + // NOTE: Extra currencies are ignored for now + let fees = computeFwdFeesVerbose( + msgPrices, + BigInt(storageStats.cells), + BigInt(storageStats.bits), + ); + // Meeh + if (fees.remaining < msg.info.forwardFee) { + // console.log(`Remaining ${fees.remaining} < ${msg.info.forwardFee} lump bits:${lumpBits}`); + storageStats = storageStats.addCells(1).addBits(lumpBits); + fees = computeFwdFeesVerbose(msgPrices, storageStats.cells, storageStats.bits); + } + if (fees.remaining != msg.info.forwardFee) { + console.log('Result fees:', fees); + console.log(msg); + console.log(fees.remaining); + throw new Error('Something went wrong in fee calcuation!'); + } + return { fees, stats: storageStats }; +} + +export const configParseMsgPrices = (sc: Slice) => { + let magic = sc.loadUint(8); + + if (magic != 0xea) { + throw Error('Invalid message prices magic number!'); + } + return { + lumpPrice: sc.loadUintBig(64), + bitPrice: sc.loadUintBig(64), + cellPrice: sc.loadUintBig(64), + ihrPriceFactor: sc.loadUintBig(32), + firstFrac: sc.loadUintBig(16), + nextFrac: sc.loadUintBig(16), + }; +}; + +export const setMsgPrices = (configRaw: Cell, prices: MsgPrices, workchain: 0 | -1) => { + const config = configRaw + .beginParse() + .loadDictDirect(Dictionary.Keys.Int(32), Dictionary.Values.Cell()); + + const priceCell = beginCell() + .storeUint(0xea, 8) + .storeUint(prices.lumpPrice, 64) + .storeUint(prices.bitPrice, 64) + .storeUint(prices.cellPrice, 64) + .storeUint(prices.ihrPriceFactor, 32) + .storeUint(prices.firstFrac, 16) + .storeUint(prices.nextFrac, 16) + .endCell(); + config.set(25 + workchain, priceCell); + + return beginCell().storeDictDirect(config).endCell(); +}; + +export const getMsgPrices = (configRaw: Cell, workchain: 0 | -1) => { + const config = configRaw + .beginParse() + .loadDictDirect(Dictionary.Keys.Int(32), Dictionary.Values.Cell()); + + const prices = config.get(25 + workchain); + + if (prices === undefined) { + throw Error('No prices defined in config'); + } + + return configParseMsgPrices(prices.beginParse()); +}; + +export function computeFwdFees(msgPrices: MsgPrices, cells: bigint, bits: bigint) { + return msgPrices.lumpPrice + shr16ceil(msgPrices.bitPrice * bits + msgPrices.cellPrice * cells); +} + +export function computeFwdFeesVerbose( + msgPrices: MsgPrices, + cells: bigint | number, + bits: bigint | number, +) { + const fees = computeFwdFees(msgPrices, BigInt(cells), BigInt(bits)); + + const res = (fees * msgPrices.firstFrac) >> 16n; + return { + total: fees, + res, + remaining: fees - res, + }; +} diff --git a/standard/wallets/comparison/tests/wallet-fee-comparison.spec.ts b/standard/wallets/comparison/tests/wallet-fee-comparison.spec.ts new file mode 100644 index 0000000..3692bbb --- /dev/null +++ b/standard/wallets/comparison/tests/wallet-fee-comparison.spec.ts @@ -0,0 +1,183 @@ +import { Cell, beginCell } from '@ton/core'; +import { randomAddress } from '@ton/test-utils'; +import { + MessageBodyResolver, + runAllMeasurements, + WALLET_CONFIGS, + DEFAULT_CONSTANTS, +} from './get-results'; +import { printTables } from './print-tables'; + +type MessageBodyConfig = { + name: string; + resolveBody: MessageBodyResolver; +}; + +type TestRunConfig = { + messageCount: number; + bodyResolver: MessageBodyResolver; + bodyName: string; +}; + +type WalletKey = + | 'v2r1' + | 'v2r2' + | 'v3r1' + | 'v3r2' + | 'v4r2' + | 'v5r1' + | 'preprocessedV2' + | 'highloadV3'; + +type EnabledWallets = Record; + +type ColumnAlignment = 'left' | 'center' | 'right'; + +type ColumnConfig = { + key: string; + header: string; + alignment: ColumnAlignment; + enabled: boolean; +}; + +type Config = { + requestTimings: { + realSeconds: number; + theoreticalSeconds: number; + }; + messageCounts: number[]; + messageBodyVariants: MessageBodyConfig[]; + enabledWallets: EnabledWallets; + columnOrder: ColumnConfig[]; + testRuns: TestRunConfig[]; + outputDirectory: string; +}; + +function buildTestRuns(bodyVariants: MessageBodyConfig[], counts: number[]): TestRunConfig[] { + return bodyVariants.flatMap((variant) => + counts.map((messageCount) => ({ + messageCount, + bodyResolver: variant.resolveBody, + bodyName: variant.name, + })), + ); +} + +function commentBodyResolver(messageIndex: number): Cell { + return beginCell().storeUint(0, 32).storeStringTail(randomString(12, messageIndex)).endCell(); +} + +function jettonBodyResolver(messageIndex: number): Cell { + return beginCell() + .storeUint(0xf8a7ea5, 32) + .storeUint(messageIndex, 64) + .storeCoins(1) + .storeAddress(randomAddress()) + .storeAddress(randomAddress()) + .storeMaybeRef(null) + .storeCoins(0) + .storeMaybeRef(commentBodyResolver(messageIndex)) + .endCell(); +} + +function randomString(size: number, seed: number): string { + return generateSeededString(seed, size); +} + +function generateSeededString( + seed: number, + size: number, + characterSet: string = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789', +): string { + const randomFunc = mulberry32(seed); + + let result = ''; + for (let i = 0; i < size; i++) { + const randomIndex = Math.floor(randomFunc() * characterSet.length); + result += characterSet.charAt(randomIndex); + } + return result; +} + +function mulberry32(seed: number): () => number { + return function () { + let t = (seed += 0x6d2b79f5); + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +const CONFIG: Config = (() => { + const messageCounts = [1, 4, 200, 1000]; + const messageBodyVariants: MessageBodyConfig[] = [ + { name: 'Sending TONs', resolveBody: () => Cell.EMPTY }, + { name: 'Sending Comment', resolveBody: commentBodyResolver }, + { name: 'Sending Jettons', resolveBody: jettonBodyResolver }, + ]; + + const enabledWallets = { + v2r1: true, + v2r2: true, + v3r1: true, + v3r2: true, + v4r2: true, + v5r1: true, + preprocessedV2: true, + highloadV3: true, + } satisfies EnabledWallets; + + const columnOrder: ColumnConfig[] = [ + { key: 'walletVersion', header: 'Wallet Version', alignment: 'center', enabled: true }, + { key: 'gasDelta', header: 'Gas delta %', alignment: 'right', enabled: true }, + { key: 'feeDelta', header: 'Fee delta %', alignment: 'right', enabled: true }, + { key: 'requests', header: 'Requests', alignment: 'right', enabled: true }, + { key: 'totalGas', header: 'Total Gas', alignment: 'right', enabled: true }, + { key: 'gasPerMsg', header: 'Gas/Msg', alignment: 'right', enabled: true }, + { key: 'totalFee', header: 'Total Fee (TON)', alignment: 'right', enabled: true }, + { key: 'feePerMsg', header: 'Fee/Msg (TON)', alignment: 'right', enabled: true }, + { key: 'realTime', header: 'Real Time (s)', alignment: 'right', enabled: true }, + { key: 'theoryTime', header: 'Theory Time (s)', alignment: 'right', enabled: true }, + ]; + + return { + requestTimings: { + realSeconds: 13, + theoreticalSeconds: 4, + }, + messageCounts, + messageBodyVariants, + enabledWallets, + columnOrder, + testRuns: buildTestRuns(messageBodyVariants, messageCounts), + outputDirectory: __dirname, + }; +})(); + +describe('Wallet Fee Comparison', () => { + it('Run all wallet measurements', async () => { + const allResults = await runAllMeasurements({ + enabledWallets: CONFIG.enabledWallets, + testRuns: CONFIG.testRuns, + constants: DEFAULT_CONSTANTS, + }); + + const walletNames = WALLET_CONFIGS.reduce( + (acc, config) => { + acc[config.key] = config.name; + return acc; + }, + {} as Record, + ); + + await printTables({ + allResults, + testRuns: CONFIG.testRuns, + columnOrder: CONFIG.columnOrder, + requestTimings: CONFIG.requestTimings, + highloadWalletName: walletNames['highloadV3'], + preprocessedWalletName: walletNames['preprocessedV2'], + outputDirectory: CONFIG.outputDirectory, + }); + }); +}); diff --git a/standard/wallets/comparison/tsconfig.json b/standard/wallets/comparison/tsconfig.json new file mode 100644 index 0000000..f74721e --- /dev/null +++ b/standard/wallets/comparison/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020"], + "outDir": "./dist", + "rootDir": "./", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "moduleResolution": "node" + }, + "include": ["wrappers/**/*", "tests/**/*", "scripts/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/standard/wallets/comparison/utils.ts b/standard/wallets/comparison/utils.ts new file mode 100644 index 0000000..c63606d --- /dev/null +++ b/standard/wallets/comparison/utils.ts @@ -0,0 +1,7 @@ +const getRandom = (min: number, max: number) => { + return Math.random() * (max - min) + min; +}; + +export const getRandomInt = (min: number, max: number) => { + return Math.round(getRandom(min, max)); +}; diff --git a/standard/wallets/comparison/wrappers/highload-query-id.ts b/standard/wallets/comparison/wrappers/highload-query-id.ts new file mode 100644 index 0000000..31b6309 --- /dev/null +++ b/standard/wallets/comparison/wrappers/highload-query-id.ts @@ -0,0 +1,80 @@ +const BIT_NUMBER_SIZE = 10n; // 10 bit +const SHIFT_SIZE = 13n; // 13 bit +const MAX_BIT_NUMBER = 1022n; +const MAX_SHIFT = 8191n; // 2^13 = 8192 + +export class HighloadQueryId { + private shift: bigint; // [0 .. 8191] + private bitnumber: bigint; // [0 .. 1022] + + constructor() { + this.shift = 0n; + this.bitnumber = 0n; + } + + static fromShiftAndBitNumber(shift: bigint, bitnumber: bigint): HighloadQueryId { + const q = new HighloadQueryId(); + q.shift = shift; + if (q.shift < 0) throw new Error('invalid shift'); + if (q.shift > MAX_SHIFT) throw new Error('invalid shift'); + q.bitnumber = bitnumber; + if (q.bitnumber < 0) throw new Error('invalid bitnumber'); + if (q.bitnumber > MAX_BIT_NUMBER) throw new Error('invalid bitnumber'); + return q; + } + + getNext() { + let newBitnumber = this.bitnumber + 1n; + let newShift = this.shift; + + if (newShift === MAX_SHIFT && newBitnumber > MAX_BIT_NUMBER - 1n) { + throw new Error('Overload'); // NOTE: we left one queryId for emergency withdraw + } + + if (newBitnumber > MAX_BIT_NUMBER) { + newBitnumber = 0n; + newShift += 1n; + if (newShift > MAX_SHIFT) { + throw new Error('Overload'); + } + } + + return HighloadQueryId.fromShiftAndBitNumber(newShift, newBitnumber); + } + + hasNext() { + const isEnd = this.bitnumber >= MAX_BIT_NUMBER - 1n && this.shift === MAX_SHIFT; // NOTE: we left one queryId for emergency withdraw; + return !isEnd; + } + + getShift(): bigint { + return this.shift; + } + + getBitNumber(): bigint { + return this.bitnumber; + } + + getQueryId(): bigint { + return (this.shift << BIT_NUMBER_SIZE) + this.bitnumber; + } + + static fromQueryId(queryId: bigint): HighloadQueryId { + const shift = queryId >> BIT_NUMBER_SIZE; + const bitnumber = queryId & 1023n; + return this.fromShiftAndBitNumber(shift, bitnumber); + } + + static fromSeqno(i: bigint): HighloadQueryId { + const shift = i / 1023n; + const bitnumber = i % 1023n; + return this.fromShiftAndBitNumber(shift, bitnumber); + } + + /** + * @return {bigint} [0 .. 8380415] + */ + toSeqno(): bigint { + return this.bitnumber + this.shift * 1023n; + } +} diff --git a/standard/wallets/comparison/wrappers/highload-wallet-v3.ts b/standard/wallets/comparison/wrappers/highload-wallet-v3.ts new file mode 100644 index 0000000..a110440 --- /dev/null +++ b/standard/wallets/comparison/wrappers/highload-wallet-v3.ts @@ -0,0 +1,233 @@ +import { + Address, + beginCell, + Cell, + Contract, + contractAddress, + ContractProvider, + internal as internal_relaxed, + MessageRelaxed, + OutAction, + OutActionSendMsg, + Sender, + SendMode, + storeMessageRelaxed, + storeOutList, + toNano, +} from '@ton/core'; +import { sign } from '@ton/crypto'; +import { OP } from '../tests/imports/const'; +import { HighloadQueryId } from './highload-query-id'; + +const HighloadWalletV3CodeHex = + 'b5ee9c7241021001000228000114ff00f4a413f4bcf2c80b01020120020d02014803040078d020d74bc00101c060b0915be101d0d3030171b0915be0fa4030f828c705b39130e0d31f018210ae42e5a4ba9d8040d721d74cf82a01ed55fb04e030020120050a02027306070011adce76a2686b85ffc00201200809001aabb6ed44d0810122d721d70b3f0018aa3bed44d08307d721d70b1f0201200b0c001bb9a6eed44d0810162d721d70b15800e5b8bf2eda2edfb21ab09028409b0ed44d0810120d721f404f404d33fd315d1058e1bf82325a15210b99f326df82305aa0015a112b992306dde923033e2923033e25230800df40f6fa19ed021d721d70a00955f037fdb31e09130e259800df40f6fa19cd001d721d70a00937fdb31e0915be270801f6f2d48308d718d121f900ed44d0d3ffd31ff404f404d33fd315d1f82321a15220b98e12336df82324aa00a112b9926d32de58f82301de541675f910f2a106d0d31fd4d307d30cd309d33fd315d15168baf2a2515abaf2a6f8232aa15250bcf2a304f823bbf2a35304800df40f6fa199d024d721d70a00f2649130e20e01fe5309800df40f6fa18e13d05004d718d20001f264c858cf16cf8301cf168e1030c824cf40cf8384095005a1a514cf40e2f800c94039800df41704c8cbff13cb1ff40012f40012cb3f12cb15c9ed54f80f21d0d30001f265d3020171b0925f03e0fa4001d70b01c000f2a5fa4031fa0031f401fa0031fa00318060d721d300010f0020f265d2000193d431d19130e272b1fb00b585bf03'; + +export const HighloadWalletV3Code = Cell.fromBoc(Buffer.from(HighloadWalletV3CodeHex, 'hex'))[0]; + +export type HighloadWalletV3Config = { + publicKey: Buffer; + subwalletId: number; + timeout: number; +}; + +export const TIMESTAMP_SIZE = 64; +export const TIMEOUT_SIZE = 22; + +export function highloadWalletV3ConfigToCell(config: HighloadWalletV3Config): Cell { + return beginCell() + .storeBuffer(config.publicKey) + .storeUint(config.subwalletId, 32) + .storeUint(0, 1 + 1 + TIMESTAMP_SIZE) + .storeUint(config.timeout, TIMEOUT_SIZE) + .endCell(); +} + +export class HighloadWalletV3 implements Contract { + constructor( + readonly address: Address, + readonly init?: { code: Cell; data: Cell }, + ) {} + + static createFromAddress(address: Address) { + return new HighloadWalletV3(address); + } + + static createFromConfig(config: HighloadWalletV3Config, code: Cell, workchain = 0) { + const data = highloadWalletV3ConfigToCell(config); + const init = { code, data }; + return new HighloadWalletV3(contractAddress(workchain, init), init); + } + + async sendDeploy(provider: ContractProvider, via: Sender, value: bigint) { + await provider.internal(via, { + value, + bounce: false, + sendMode: SendMode.PAY_GAS_SEPARATELY, + body: beginCell().endCell(), + }); + } + + async sendExternalMessage( + provider: ContractProvider, + secretKey: Buffer, + opts: { + message: MessageRelaxed | Cell; + mode: number; + query_id: bigint | HighloadQueryId; + createdAt: number; + subwalletId: number; + timeout: number; + }, + ) { + let messageCell: Cell; + + if (opts.message instanceof Cell) { + messageCell = opts.message; + } else { + const messageBuilder = beginCell(); + messageBuilder.store(storeMessageRelaxed(opts.message)); + messageCell = messageBuilder.endCell(); + } + + const queryId = + opts.query_id instanceof HighloadQueryId ? opts.query_id.getQueryId() : opts.query_id; + + const messageInner = beginCell() + .storeUint(opts.subwalletId, 32) + .storeRef(messageCell) + .storeUint(opts.mode, 8) + .storeUint(queryId, 23) + .storeUint(opts.createdAt, TIMESTAMP_SIZE) + .storeUint(opts.timeout, TIMEOUT_SIZE) + .endCell(); + + await provider.external( + beginCell() + .storeBuffer(sign(messageInner.hash(), secretKey)) + .storeRef(messageInner) + .endCell(), + ); + } + + async sendBatch( + provider: ContractProvider, + secretKey: Buffer, + messages: OutActionSendMsg[], + subwallet: number, + query_id: HighloadQueryId, + timeout: number, + createdAt?: number, + value: bigint = 0n, + ) { + if (createdAt == undefined) { + createdAt = Math.floor(Date.now() / 1000); + } + return await this.sendExternalMessage(provider, secretKey, { + message: this.packActions(messages, value, query_id), + mode: value > 0n ? SendMode.PAY_GAS_SEPARATELY : SendMode.CARRY_ALL_REMAINING_BALANCE, + query_id: query_id, + createdAt: createdAt, + subwalletId: subwallet, + timeout: timeout, + }); + } + + static createInternalTransferBody(opts: { + actions: OutAction[] | Cell; + queryId: HighloadQueryId; + }) { + let actionsCell: Cell; + if (opts.actions instanceof Cell) { + actionsCell = opts.actions; + } else { + if (opts.actions.length > 254) { + throw TypeError('Max allowed action count is 254. Use packActions instead.'); + } + const actionsBuilder = beginCell(); + storeOutList(opts.actions)(actionsBuilder); + actionsCell = actionsBuilder.endCell(); + } + return beginCell() + .storeUint(OP.InternalTransfer, 32) + .storeUint(opts.queryId.getQueryId(), 64) + .storeRef(actionsCell) + .endCell(); + } + + createInternalTransfer(opts: { + actions: OutAction[] | Cell; + queryId: HighloadQueryId; + value: bigint; + }) { + return internal_relaxed({ + to: this.address, + value: opts.value, + body: HighloadWalletV3.createInternalTransferBody(opts), + }); + /*beginCell() + .storeUint(0x10, 6) + .storeAddress(this.address) + .storeCoins(opts.value) + .storeUint(0, 107) + .storeSlice(body.asSlice()) + .endCell(); + */ + } + + packActions(messages: OutAction[], value: bigint = toNano('1'), query_id: HighloadQueryId) { + let batch: OutAction[]; + if (messages.length > 254) { + batch = messages.slice(0, 253); + batch.push({ + type: 'sendMsg', + mode: value > 0n ? SendMode.PAY_GAS_SEPARATELY : SendMode.CARRY_ALL_REMAINING_BALANCE, + outMsg: this.packActions(messages.slice(253), value, query_id), + }); + } else { + batch = messages; + } + return this.createInternalTransfer({ + actions: batch, + queryId: query_id, + value, + }); + } + + async getPublicKey(provider: ContractProvider): Promise { + const res = (await provider.get('get_public_key', [])).stack; + const pubKeyU = res.readBigNumber(); + return Buffer.from(pubKeyU.toString(16).padStart(32 * 2, '0'), 'hex'); + } + + async getSubwalletId(provider: ContractProvider): Promise { + const res = (await provider.get('get_subwallet_id', [])).stack; + return res.readNumber(); + } + + async getTimeout(provider: ContractProvider): Promise { + const res = (await provider.get('get_timeout', [])).stack; + return res.readNumber(); + } + + async getLastCleaned(provider: ContractProvider): Promise { + const res = (await provider.get('get_last_clean_time', [])).stack; + return res.readNumber(); + } + + async getProcessed( + provider: ContractProvider, + queryId: HighloadQueryId, + needClean = true, + ): Promise { + const res = ( + await provider.get('processed?', [ + { type: 'int', value: queryId.getQueryId() }, + { + type: 'int', + value: needClean ? -1n : 0n, + }, + ]) + ).stack; + return res.readBoolean(); + } +} diff --git a/standard/wallets/comparison/wrappers/msg-generator.ts b/standard/wallets/comparison/wrappers/msg-generator.ts new file mode 100644 index 0000000..7d6acb1 --- /dev/null +++ b/standard/wallets/comparison/wrappers/msg-generator.ts @@ -0,0 +1,149 @@ +import { + Cell, + CommonMessageInfoExternalIn, + CommonMessageInfoExternalOut, + ExternalAddress, + Message, + MessageRelaxed, + StateInit, + beginCell, + external, + storeMessage, + storeMessageRelaxed, +} from '@ton/core'; +import { randomAddress } from '@ton/test-utils'; +export class MsgGenerator { + constructor(readonly wc: number) {} + + generateExternalOutWithBadSource() { + const ssrcInvalid = beginCell() + .storeUint(2, 2) // addr_std$10 + .storeUint(0, 1) // anycast nothing + .storeInt(this.wc, 8) // workchain_id: -1 + .storeUint(1, 10) + .endCell(); + + return beginCell() + .storeUint(3, 2) // ext_out_msg_info$11 + .storeBit(0) // src:INVALID + .storeSlice(ssrcInvalid.beginParse()) + .endCell(); + } + generateExternalOutWithBadDst() { + const src = randomAddress(-1); + return beginCell() + .storeUint(3, 2) // ext_out_msg_info$11 + .storeAddress(src) // src:MsgAddressInt + .storeBit(0) // dest:INVALID + .endCell(); + } + generateExternalInWithBadSource() { + const ssrcInvalid = beginCell() + .storeUint(1, 2) // addrExtern$01 + .storeUint(128, 9) + .storeUint(0, 10) + .endCell(); + + return beginCell() + .storeUint(2, 2) //ext_in_msg_info$11 + .storeSlice(ssrcInvalid.beginParse()) // src:INVALID + .endCell(); + } + generateExternalInWithBadDst() { + const src = new ExternalAddress(BigInt(Date.now()), 256); + return beginCell() + .storeUint(2, 2) //ext_in_msg_info$10 + .storeAddress(src) // src:MsgAddressExt + .storeBit(0) // dest:INVALID + .endCell(); + } + generateInternalMessageWithBadGrams() { + const src = randomAddress(this.wc); + const dst = randomAddress(this.wc); + return beginCell() + .storeUint(0, 1) // int_msg_info$0 + .storeUint(0, 1) // ihr_disabled:Bool + .storeUint(0, 1) // bounce:Bool + .storeUint(0, 1) // bounced:Bool + .storeAddress(src) // src:MsgAddress + .storeAddress(dst) // dest:MsgAddress + .storeUint(8, 4) // len of nanograms + .storeUint(1, 1) // INVALID GRAMS amount + .endCell(); + } + generateInternalMessageWithBadInitStateData() { + const ssrc = randomAddress(this.wc); + const sdest = randomAddress(this.wc); + + const init_state_with_bad_data = beginCell() + .storeUint(0, 1) // maybe (##5) + .storeUint(1, 1) // Maybe TickTock + .storeUint(1, 1) // bool Tick + .storeUint(0, 1) // bool Tock + .storeUint(1, 1) // code: Maybe Cell^ + .storeUint(1, 1) // data: Maybe Cell^ + .storeUint(1, 1); // library: Maybe ^Cell + // bits for references but no data + + return beginCell() + .storeUint(0, 1) // int_msg_info$0 + .storeUint(0, 1) // ihr_disabled:Bool + .storeUint(0, 1) // bounce:Bool + .storeUint(0, 1) // bounced:Bool + .storeAddress(ssrc) // src:MsgAddress + .storeAddress(sdest) // dest:MsgAddress + .storeCoins(0) // + .storeMaybeRef(null) // extra currencies + .storeCoins(0) // ihr_fee + .storeCoins(0) // fwd_fee + .storeUint(1000, 64) // created_lt:uint64 + .storeUint(1000, 32) // created_at:uint32 + .storeUint(1, 1) // Maybe init_state + .storeUint(1, 1) // Either (X ^X) init state + .storeRef(init_state_with_bad_data.endCell()) + .storeUint(0, 1) // Either (X ^X) body + .endCell(); + } + + *generateBadMsg() { + // Meh + yield this.generateExternalInWithBadDst(); + yield this.generateExternalOutWithBadDst(); + yield this.generateExternalInWithBadSource(); + yield this.generateExternalOutWithBadSource(); + yield this.generateInternalMessageWithBadGrams(); + yield this.generateInternalMessageWithBadInitStateData(); + } + generateExternalInMsg( + info?: Partial, + body?: Cell, + init?: StateInit, + ) { + const msgInfo: CommonMessageInfoExternalIn = { + type: 'external-in', + dest: info?.dest || randomAddress(this.wc), + src: info?.src, + importFee: info?.importFee || 0n, + }; + const newMsg: Message = { + info: msgInfo, + body: body || Cell.EMPTY, + init, + }; + return beginCell().store(storeMessage(newMsg)).endCell(); + } + generateExternalOutMsg(info?: Partial, body?: Cell) { + const msgInfo: CommonMessageInfoExternalOut = { + type: 'external-out', + createdAt: info?.createdAt || 0, + createdLt: info?.createdLt || 0n, + src: info?.src || randomAddress(this.wc), + dest: info?.dest, + }; + const newMsg: MessageRelaxed = { + info: msgInfo, + body: body || Cell.EMPTY, + }; + return beginCell().store(storeMessageRelaxed(newMsg)).endCell(); + } +} diff --git a/standard/wallets/comparison/wrappers/preprocessed-wallet-v2.ts b/standard/wallets/comparison/wrappers/preprocessed-wallet-v2.ts new file mode 100644 index 0000000..76fa572 --- /dev/null +++ b/standard/wallets/comparison/wrappers/preprocessed-wallet-v2.ts @@ -0,0 +1,172 @@ +import { + Address, + beginCell, + Cell, + Contract, + contractAddress, + ContractProvider, + Sender, + SendMode, + Slice, + StateInit, + storeStateInit, + OutAction, + OutActionSendMsg, + OutActionSetCode, + storeOutList, +} from '@ton/core'; +import { KeyPair, sign } from '@ton/crypto'; + +const MAX_ACTIONS = 255; +const DEFAULT_VALID_UNTIL_OFFSET = 60; + +export const walletCode = Cell.fromBoc( + Buffer.from( + 'B5EE9C7241010101003D000076FF00DDD40120F90001D0D33FD30FD74CED44D0D3FFD70B0F20A4830FA90822C8CBFFCB0FC9ED5444301046BAF2A1F823BEF2A2F910F2A3F800ED552E766412', + 'hex', + ), +)[0]; + +export type TransferMessage = { + to: Address; + value: bigint; + body?: Cell; + mode?: SendMode; + bounce?: boolean; + init?: StateInit; +}; + +export function createTransferAction(msg: TransferMessage): OutActionSendMsg { + const bounce = msg.bounce ?? true; + + return { + type: 'sendMsg', + mode: msg.mode ?? SendMode.PAY_GAS_SEPARATELY, + outMsg: { + info: { + type: 'internal', + ihrDisabled: true, + bounce: bounce, + bounced: false, + dest: msg.to, + value: { coins: msg.value }, + ihrFee: 0n, + forwardFee: 0n, + createdLt: 0n, + createdAt: 0, + }, + init: msg.init, + body: msg.body || Cell.EMPTY, + }, + }; +} + +export function createSetCodeAction(code: Cell): OutActionSetCode { + return { + type: 'setCode', + newCode: code, + }; +} + +export class Wallet implements Contract { + constructor( + readonly address: Address, + readonly init?: { code: Cell; data: Cell }, + ) {} + + static createFromAddress(address: Address) { + return new Wallet(address); + } + + static createFromPublicKey(publicKey: Buffer, workchain = 0) { + const data = beginCell().storeBuffer(publicKey, 32).storeUint(0, 16).endCell(); + const init = { code: walletCode, data }; + return new Wallet(contractAddress(workchain, init), init); + } + + async sendDeploy(provider: ContractProvider, via: Sender, value: bigint) { + await provider.internal(via, { + value, + sendMode: SendMode.PAY_GAS_SEPARATELY, + body: Cell.EMPTY, + }); + } + + async sendExternalMessage( + provider: ContractProvider, + keypair: KeyPair, + actions: OutAction[], + seqno: number, + validUntil?: number, + ) { + if (actions.length > MAX_ACTIONS) { + throw new Error(`Maximum ${MAX_ACTIONS} actions allowed`); + } + + if (actions.length === 0) { + throw new Error('At least one action is required'); + } + + if (validUntil === undefined) { + validUntil = Math.floor(Date.now() / 1000) + DEFAULT_VALID_UNTIL_OFFSET; + } + + const actionsCell = beginCell(); + storeOutList(actions)(actionsCell); + + const msgInner = beginCell() + .storeUint(validUntil, 64) + .storeUint(seqno & 0xffff, 16) + .storeRef(actionsCell.endCell()) + .endCell(); + const hash = msgInner.hash(); + const signature = sign(hash, keypair.secretKey); + await provider.external(beginCell().storeBuffer(signature, 64).storeRef(msgInner).endCell()); + } + + async sendTransfers( + provider: ContractProvider, + keypair: KeyPair, + transfers: TransferMessage[], + seqno: number, + validUntil?: number, + ) { + if (transfers.length === 0) { + throw new Error('At least one transfer is required'); + } + const actions = transfers.map(createTransferAction); + await this.sendExternalMessage(provider, keypair, actions, seqno, validUntil); + } + + async sendSetCode( + provider: ContractProvider, + keypair: KeyPair, + code: Cell, + seqno: number, + validUntil?: number, + ) { + const action = createSetCodeAction(code); + await this.sendExternalMessage(provider, keypair, [action], seqno, validUntil); + } + + private async getStorageParams( + provider: ContractProvider, + ): Promise<{ publicKey: Buffer; seqno: bigint } | { publicKey: undefined; seqno: bigint }> { + const state = (await provider.getState()).state; + if (state.type == 'active') { + const data = Cell.fromBoc(state.data!)[0].beginParse(); + return { publicKey: data.loadBuffer(32), seqno: data.loadUintBig(16) }; + } + return { publicKey: undefined, seqno: BigInt(0) }; + } + + async getPublicKey(provider: ContractProvider): Promise { + const { publicKey } = await this.getStorageParams(provider); + return publicKey; + } + + async getSeqno(provider: ContractProvider): Promise { + const { seqno } = await this.getStorageParams(provider); + return seqno; + } +}