diff --git a/packages/wallet-sdk/src/transaction-analyzer/index.ts b/packages/wallet-sdk/src/transaction-analyzer/index.ts index e4b026ccd..3c822743a 100644 --- a/packages/wallet-sdk/src/transaction-analyzer/index.ts +++ b/packages/wallet-sdk/src/transaction-analyzer/index.ts @@ -10,5 +10,6 @@ export type { AnalyzedObject } from './rules/objects.js'; export type { CoinFlow } from './rules/coin-flows.js'; export type { CoinValueAnalyzerOptions, CoinValueAnalysis } from './rules/coin-value.js'; export type { AnalyzedCommandInput } from './rules/inputs.js'; +export type { ParsedPureValue } from './rules/pure-values.js'; export { analyzers } from './rules/index.js'; diff --git a/packages/wallet-sdk/src/transaction-analyzer/rules/index.ts b/packages/wallet-sdk/src/transaction-analyzer/rules/index.ts index d1e74bcdb..e88ed9e64 100644 --- a/packages/wallet-sdk/src/transaction-analyzer/rules/index.ts +++ b/packages/wallet-sdk/src/transaction-analyzer/rules/index.ts @@ -10,6 +10,7 @@ import { balanceChanges, bytes, data, digest, transactionResponse } from './core import { moveFunctions } from './functions.js'; import { inputs } from './inputs.js'; import { objectIds, objects, objectsById, ownedObjects } from './objects.js'; +import { pureValues } from './pure-values.js'; export const analyzers = { accessLevel, @@ -29,4 +30,5 @@ export const analyzers = { objects, objectsById, ownedObjects, + pureValues, }; diff --git a/packages/wallet-sdk/src/transaction-analyzer/rules/pure-values.ts b/packages/wallet-sdk/src/transaction-analyzer/rules/pure-values.ts new file mode 100644 index 000000000..6303b0e3d --- /dev/null +++ b/packages/wallet-sdk/src/transaction-analyzer/rules/pure-values.ts @@ -0,0 +1,176 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +import type { SuiClientTypes } from '@mysten/sui/client'; +import { fromBase64 } from '@mysten/sui/utils'; +import { pureBcsSchemaFromTypeName } from '@mysten/sui/bcs'; +import type { PureTypeName } from '@mysten/sui/bcs'; +import { createAnalyzer } from '../analyzer.js'; +import { commands } from './commands.js'; +import type { AnalyzedCommand } from './commands.js'; + +export interface ParsedPureValue { + index: number; + bytes: string; + type: PureTypeName | null; + value: unknown; +} + +/** + * Converts an OpenSignatureBody to a PureTypeName string that can be used + * with pureBcsSchemaFromTypeName to decode BCS bytes. + * + * Returns null for types that are not pure values (e.g. datatypes, type parameters). + */ +function openSignatureBodyToPureTypeName( + body: SuiClientTypes.OpenSignatureBody, +): PureTypeName | null { + switch (body.$kind) { + case 'u8': + case 'u16': + case 'u32': + case 'u64': + case 'u128': + case 'u256': + case 'bool': + case 'address': + return body.$kind; + case 'vector': { + const inner = openSignatureBodyToPureTypeName(body.vector); + if (inner === null) return null; + return `vector<${inner}>`; + } + case 'datatype': { + // Handle well-known pure datatypes + const { typeName, typeParameters } = body.datatype; + if (typeName === '0x1::string::String' || typeName === '0x1::ascii::String') { + return 'string'; + } + if (typeName === '0x2::object::ID') { + return 'id'; + } + if (typeName === '0x1::option::Option' && typeParameters.length === 1) { + const inner = openSignatureBodyToPureTypeName(typeParameters[0]); + if (inner === null) return null; + return `option<${inner}>`; + } + return null; + } + case 'typeParameter': + case 'unknown': + default: + return null; + } +} + +/** + * Infers the pure type for a given pure input index from the commands that use it. + * For MoveCall commands, uses the function signature parameters. + * For built-in commands (SplitCoins, TransferObjects), uses known types. + */ +function inferPureType( + inputIndex: number, + analyzedCommands: AnalyzedCommand[], +): PureTypeName | null { + for (const cmd of analyzedCommands) { + switch (cmd.$kind) { + case 'MoveCall': { + for (let i = 0; i < cmd.arguments.length; i++) { + const arg = cmd.arguments[i]; + if (arg.$kind === 'Pure' && arg.index === inputIndex) { + const param = cmd.function.parameters[i]; + if (param) { + return openSignatureBodyToPureTypeName(param.body); + } + } + } + break; + } + case 'SplitCoins': { + for (const amt of cmd.amounts) { + if (amt.$kind === 'Pure' && amt.index === inputIndex) { + return 'u64'; + } + } + break; + } + case 'TransferObjects': { + if (cmd.address.$kind === 'Pure' && cmd.address.index === inputIndex) { + return 'address'; + } + break; + } + // MergeCoins, MakeMoveVec, Upgrade, Publish don't typically use pure inputs + } + } + return null; +} + +/** + * Analyzes all pure inputs in a transaction and attempts to decode their BCS bytes + * into JavaScript values using type information from the commands that reference them. + */ +export const pureValues = createAnalyzer({ + dependencies: { commands }, + analyze: + () => + ({ commands }) => { + const results: ParsedPureValue[] = []; + + // Collect all unique pure inputs from all commands + const seenIndices = new Set(); + + for (const cmd of commands) { + const args = getPureArgsFromCommand(cmd); + for (const arg of args) { + if (arg.$kind === 'Pure' && !seenIndices.has(arg.index)) { + seenIndices.add(arg.index); + + const type = inferPureType(arg.index, commands); + let value: unknown = null; + + if (type !== null) { + try { + const schema = pureBcsSchemaFromTypeName(type); + value = schema.parse(fromBase64(arg.bytes)); + } catch { + // If parsing fails, leave value as null + } + } + + results.push({ + index: arg.index, + bytes: arg.bytes, + type, + value, + }); + } + } + } + + // Sort by input index for deterministic output + results.sort((a, b) => a.index - b.index); + + return { result: results }; + }, +}); + +function getPureArgsFromCommand(cmd: AnalyzedCommand) { + switch (cmd.$kind) { + case 'MoveCall': + return cmd.arguments; + case 'SplitCoins': + return [cmd.coin, ...cmd.amounts]; + case 'TransferObjects': + return [cmd.address, ...cmd.objects]; + case 'MergeCoins': + return [cmd.destination, ...cmd.sources]; + case 'MakeMoveVec': + return cmd.elements; + case 'Upgrade': + return [cmd.ticket]; + case 'Publish': + case 'Unknown': + return []; + } +} diff --git a/packages/wallet-sdk/test/transaction-analyzer/pure-values.test.ts b/packages/wallet-sdk/test/transaction-analyzer/pure-values.test.ts new file mode 100644 index 000000000..e5d8c9b5e --- /dev/null +++ b/packages/wallet-sdk/test/transaction-analyzer/pure-values.test.ts @@ -0,0 +1,383 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, it, expect } from 'vitest'; +import { Transaction } from '@mysten/sui/transactions'; +import { analyze } from '../../src/transaction-analyzer/analyzer.js'; +import { pureValues } from '../../src/transaction-analyzer/rules/pure-values.js'; +import { MockSuiClient } from '../mocks/MockSuiClient.js'; +import { DEFAULT_SENDER, TEST_COIN_1_ID, TEST_NFT_ID } from '../mocks/mockData.js'; + +describe('TransactionAnalyzer - PureValues Rule', () => { + it('should parse pure values from MoveCall arguments', async () => { + const client = new MockSuiClient(); + const tx = new Transaction(); + tx.setSender(DEFAULT_SENDER); + + // MoveCall with u64, address, and bool pure args + // Uses 0x999::test::transfer which has params: (NFT &mut, u64, address, bool) + const nft = tx.object(TEST_NFT_ID); + tx.moveCall({ + target: '0x999::test::transfer', + arguments: [nft, tx.pure.u64(42n), tx.pure.address('0xabc'), tx.pure.bool(true)], + }); + + const results = await analyze( + { pureValues }, + { + client, + transaction: await tx.toJSON(), + }, + ); + + expect(results.pureValues.result).toHaveLength(3); + + expect(results.pureValues.result?.[0].type).toBe('u64'); + expect(results.pureValues.result?.[0].value).toBe('42'); + + expect(results.pureValues.result?.[1].type).toBe('address'); + expect(results.pureValues.result?.[1].value).toBe( + '0x0000000000000000000000000000000000000000000000000000000000000abc', + ); + + expect(results.pureValues.result?.[2].type).toBe('bool'); + expect(results.pureValues.result?.[2].value).toBe(true); + }); + + it('should parse pure values from SplitCoins amounts', async () => { + const client = new MockSuiClient(); + const tx = new Transaction(); + tx.setSender(DEFAULT_SENDER); + + tx.splitCoins(tx.gas, [100, 200, 300]); + + const results = await analyze( + { pureValues }, + { + client, + transaction: await tx.toJSON(), + }, + ); + + expect(results.pureValues.result).toHaveLength(3); + expect(results.pureValues.result?.[0].type).toBe('u64'); + expect(results.pureValues.result?.[0].value).toBe('100'); + expect(results.pureValues.result?.[1].type).toBe('u64'); + expect(results.pureValues.result?.[1].value).toBe('200'); + expect(results.pureValues.result?.[2].type).toBe('u64'); + expect(results.pureValues.result?.[2].value).toBe('300'); + }); + + it('should parse pure values from TransferObjects address', async () => { + const client = new MockSuiClient(); + const tx = new Transaction(); + tx.setSender(DEFAULT_SENDER); + + const coin = tx.object(TEST_COIN_1_ID); + tx.transferObjects([coin], tx.pure.address('0x456')); + + const results = await analyze( + { pureValues }, + { + client, + transaction: await tx.toJSON(), + }, + ); + + expect(results.pureValues.result).toHaveLength(1); + expect(results.pureValues.result?.[0].type).toBe('address'); + expect(results.pureValues.result?.[0].value).toBe( + '0x0000000000000000000000000000000000000000000000000000000000000456', + ); + }); + + it('should parse vector pure values', async () => { + const client = new MockSuiClient(); + const tx = new Transaction(); + tx.setSender(DEFAULT_SENDER); + + // Uses 0x999::test::complex_transfer which has params: + // (vector> &mut, u64, address, bool, vector) + const coin = tx.object(TEST_COIN_1_ID); + const coinVec = tx.makeMoveVec({ elements: [coin] }); + tx.moveCall({ + target: '0x999::test::complex_transfer', + arguments: [ + coinVec, + tx.pure.u64(100n), + tx.pure.address('0x1'), + tx.pure.bool(false), + tx.pure('vector', [1, 2, 3]), + ], + }); + + const results = await analyze( + { pureValues }, + { + client, + transaction: await tx.toJSON(), + }, + ); + + expect(results.pureValues.result).toHaveLength(4); + + const u64Val = results.pureValues.result?.find((v) => v.type === 'u64'); + expect(u64Val?.value).toBe('100'); + + const addrVal = results.pureValues.result?.find((v) => v.type === 'address'); + expect(addrVal?.value).toBe( + '0x0000000000000000000000000000000000000000000000000000000000000001', + ); + + const boolVal = results.pureValues.result?.find((v) => v.type === 'bool'); + expect(boolVal?.value).toBe(false); + + const vecVal = results.pureValues.result?.find((v) => v.type === 'vector'); + expect(vecVal?.value).toEqual([1, 2, 3]); + }); + + it('should handle mixed commands with multiple pure values', async () => { + const client = new MockSuiClient(); + const tx = new Transaction(); + tx.setSender(DEFAULT_SENDER); + + // SplitCoins with pure u64 + const splitResult = tx.splitCoins(tx.gas, [500]); + + // TransferObjects with pure address + tx.transferObjects([splitResult], tx.pure.address('0x789')); + + const results = await analyze( + { pureValues }, + { + client, + transaction: await tx.toJSON(), + }, + ); + + expect(results.pureValues.result).toHaveLength(2); + + const u64Val = results.pureValues.result?.find((v) => v.type === 'u64'); + expect(u64Val?.value).toBe('500'); + + const addrVal = results.pureValues.result?.find((v) => v.type === 'address'); + expect(addrVal?.value).toBe( + '0x0000000000000000000000000000000000000000000000000000000000000789', + ); + }); + + it('should return null type and value for unresolvable pure inputs', async () => { + const client = new MockSuiClient(); + + // Register a function with a datatype parameter that isn't a known pure type + client.addMoveFunction({ + packageId: '0x999', + moduleName: 'test', + name: 'take_struct', + visibility: 'public', + isEntry: false, + parameters: [ + { + reference: null, + body: { + $kind: 'datatype', + datatype: { typeName: '0x999::test::MyStruct', typeParameters: [] }, + }, + }, + ], + }); + + const tx = new Transaction(); + tx.setSender(DEFAULT_SENDER); + + // Pass a raw pure value to a function expecting a custom struct + tx.moveCall({ + target: '0x999::test::take_struct', + arguments: [tx.pure('u8', 42)], + }); + + const results = await analyze( + { pureValues }, + { + client, + transaction: await tx.toJSON(), + }, + ); + + expect(results.pureValues.result).toHaveLength(1); + expect(results.pureValues.result?.[0].type).toBeNull(); + expect(results.pureValues.result?.[0].value).toBeNull(); + }); + + it('should handle string pure values via 0x1::string::String', async () => { + const client = new MockSuiClient(); + + client.addMoveFunction({ + packageId: '0x999', + moduleName: 'test', + name: 'take_string', + visibility: 'public', + isEntry: false, + parameters: [ + { + reference: null, + body: { + $kind: 'datatype', + datatype: { typeName: '0x1::string::String', typeParameters: [] }, + }, + }, + ], + }); + + const tx = new Transaction(); + tx.setSender(DEFAULT_SENDER); + + tx.moveCall({ + target: '0x999::test::take_string', + arguments: [tx.pure.string('hello world')], + }); + + const results = await analyze( + { pureValues }, + { + client, + transaction: await tx.toJSON(), + }, + ); + + expect(results.pureValues.result).toHaveLength(1); + expect(results.pureValues.result?.[0].type).toBe('string'); + expect(results.pureValues.result?.[0].value).toBe('hello world'); + }); + + it('should handle 0x2::object::ID pure values', async () => { + const client = new MockSuiClient(); + + client.addMoveFunction({ + packageId: '0x999', + moduleName: 'test', + name: 'take_id', + visibility: 'public', + isEntry: false, + parameters: [ + { + reference: null, + body: { + $kind: 'datatype', + datatype: { typeName: '0x2::object::ID', typeParameters: [] }, + }, + }, + ], + }); + + const tx = new Transaction(); + tx.setSender(DEFAULT_SENDER); + + tx.moveCall({ + target: '0x999::test::take_id', + arguments: [tx.pure.id('0xdeadbeef')], + }); + + const results = await analyze( + { pureValues }, + { + client, + transaction: await tx.toJSON(), + }, + ); + + expect(results.pureValues.result).toHaveLength(1); + expect(results.pureValues.result?.[0].type).toBe('id'); + expect(results.pureValues.result?.[0].value).toBe( + '0x00000000000000000000000000000000000000000000000000000000deadbeef', + ); + }); + + it('should handle option pure values', async () => { + const client = new MockSuiClient(); + + client.addMoveFunction({ + packageId: '0x999', + moduleName: 'test', + name: 'take_option', + visibility: 'public', + isEntry: false, + parameters: [ + { + reference: null, + body: { + $kind: 'datatype', + datatype: { + typeName: '0x1::option::Option', + typeParameters: [{ $kind: 'u64' }], + }, + }, + }, + ], + }); + + const tx = new Transaction(); + tx.setSender(DEFAULT_SENDER); + + tx.moveCall({ + target: '0x999::test::take_option', + arguments: [tx.pure('option', 123n)], + }); + + const results = await analyze( + { pureValues }, + { + client, + transaction: await tx.toJSON(), + }, + ); + + expect(results.pureValues.result).toHaveLength(1); + expect(results.pureValues.result?.[0].type).toBe('option'); + expect(results.pureValues.result?.[0].value).toBe('123'); + }); + + it('should not include duplicate entries when a pure input is used in multiple commands', async () => { + const client = new MockSuiClient(); + const tx = new Transaction(); + tx.setSender(DEFAULT_SENDER); + + // Split using the same amount for two separate splits + const amount = tx.pure.u64(100n); + tx.splitCoins(tx.gas, [amount]); + tx.splitCoins(tx.gas, [amount]); + + const results = await analyze( + { pureValues }, + { + client, + transaction: await tx.toJSON(), + }, + ); + + // Should only appear once despite being used in two commands + expect(results.pureValues.result).toHaveLength(1); + expect(results.pureValues.result?.[0].type).toBe('u64'); + expect(results.pureValues.result?.[0].value).toBe('100'); + }); + + it('should return empty result for transaction with no pure inputs', async () => { + const client = new MockSuiClient(); + const tx = new Transaction(); + tx.setSender(DEFAULT_SENDER); + + // Only object operations, no pure inputs + const coin1 = tx.object(TEST_COIN_1_ID); + tx.mergeCoins(tx.gas, [coin1]); + + const results = await analyze( + { pureValues }, + { + client, + transaction: await tx.toJSON(), + }, + ); + + expect(results.pureValues.result).toHaveLength(0); + }); +});