Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/red-lemons-hope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@3loop/transaction-decoder': minor
---

Added deep-nested decoding of calldata parameters for all transactions. Added 2 new keys to detect calldata in the parameters: \_data and \_target
6 changes: 6 additions & 0 deletions apps/web/src/app/calldata/[chainID]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import * as React from 'react'
import DecodingForm from '@/app/calldata/form'

export default async function CalldataPage({ params }: { params: { chainID: number } }) {
return <DecodingForm chainID={params.chainID} />
}
8 changes: 4 additions & 4 deletions apps/web/src/app/decode/[chainID]/[hash]/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,20 @@ import { NetworkSelect } from '@/components/ui/network-select'
import { ExampleTransactions } from '@/components/ui/examples'

interface FormProps {
currentChainID: number
chainID: number
decoded?: DecodedTransaction
currentHash?: string
}

const PATH = 'decode'

export default function DecodingForm({ decoded, currentHash, currentChainID }: FormProps) {
export default function DecodingForm({ decoded, currentHash, chainID }: FormProps) {
const router = useRouter()

const onSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
const hash = (e.target as any).hash.value
router.push(`/${PATH}/${currentChainID}/${hash}`)
router.push(`/${PATH}/${chainID}/${hash}`)
}

return (
Expand All @@ -33,7 +33,7 @@ export default function DecodingForm({ decoded, currentHash, currentChainID }: F
<div className="flex w-full lg:items-center gap-2 flex-col lg:flex-row">
<div>
<NetworkSelect
defaultValue={currentChainID.toString()}
defaultValue={chainID.toString()}
onValueChange={(value) => {
const hash = currentHash || ''
router.push(`/${PATH}/${value}/${hash}`)
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/app/decode/[chainID]/[hash]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ export default async function TransactionPage({ params }: { params: { hash: stri
})

if (!decoded || !decoded.toAddress) {
return <DecodingForm currentHash={params.hash} currentChainID={params.chainID} />
return <DecodingForm currentHash={params.hash} chainID={params.chainID} />
}

return <DecodingForm decoded={decoded} currentHash={params.hash} currentChainID={params.chainID} />
return <DecodingForm decoded={decoded} currentHash={params.hash} chainID={params.chainID} />
}
6 changes: 6 additions & 0 deletions apps/web/src/app/decode/[chainID]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import * as React from 'react'
import DecodingForm from './[hash]/form'

export default async function TransactionPage({ params }: { params: { chainID: number } }) {
return <DecodingForm chainID={params.chainID} />
}
2 changes: 1 addition & 1 deletion apps/web/src/app/decode/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,5 @@ import DecodingForm from './[chainID]/[hash]/form'
import { DEFAULT_CHAIN_ID } from '../data'

export default function TransactionsPlayground() {
return <DecodingForm currentChainID={DEFAULT_CHAIN_ID} />
return <DecodingForm chainID={DEFAULT_CHAIN_ID} />
}
11 changes: 5 additions & 6 deletions apps/web/src/app/interpret/[chainID]/[hash]/form.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
'use client'
import * as React from 'react'
import { Label } from '@/components/ui/label'
import { DEFAULT_CHAIN_ID, geSidebarNavItems, EXAMPLE_TXS, INTERPRETER_REPO } from '@/app/data'
import { EXAMPLE_TXS, INTERPRETER_REPO } from '@/app/data'
import { useLocalStorage } from 'usehooks-ts'
import { SidebarNav } from '@/components/ui/sidebar-nav'
import { PlayIcon } from '@radix-ui/react-icons'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
Expand All @@ -16,14 +15,14 @@ import { fallbackInterpreter, getInterpreter } from '@3loop/transaction-interpre
import { ExampleTransactions } from '@/components/ui/examples'

interface FormProps {
currentChainID: number
chainID: number
decoded?: DecodedTransaction
currentHash?: string
}

const PATH = 'interpret'

export default function DecodingForm({ decoded, currentHash, currentChainID }: FormProps) {
export default function DecodingForm({ decoded, currentHash, chainID }: FormProps) {
const [result, setResult] = React.useState<Interpretation>()
const [persistedSchema, setSchema] = useLocalStorage(decoded?.toAddress ?? 'unknown', '')

Expand All @@ -44,7 +43,7 @@ export default function DecodingForm({ decoded, currentHash, currentChainID }: F
const onSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
const hash = (e.target as any).hash.value
router.push(`/${PATH}/${currentChainID}/${hash}`)
router.push(`/${PATH}/${chainID}/${hash}`)
}

const onRun = React.useCallback(() => {
Expand Down Expand Up @@ -84,7 +83,7 @@ export default function DecodingForm({ decoded, currentHash, currentChainID }: F
<div className="flex w-full lg:items-center gap-2 flex-col lg:flex-row">
<div>
<NetworkSelect
defaultValue={currentChainID.toString()}
defaultValue={chainID.toString()}
onValueChange={(value) => {
const hash = currentHash || ''
router.push(`/${PATH}/${value}/${hash}`)
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/app/interpret/[chainID]/[hash]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ export default async function TransactionPage({ params }: { params: { hash: stri
})

if (!decoded || !decoded.toAddress) {
return <DecodingForm currentHash={params.hash} currentChainID={params.chainID} />
return <DecodingForm currentHash={params.hash} chainID={params.chainID} />
}

return <DecodingForm decoded={decoded} currentHash={params.hash} currentChainID={params.chainID} />
return <DecodingForm decoded={decoded} currentHash={params.hash} chainID={params.chainID} />
}
2 changes: 1 addition & 1 deletion apps/web/src/app/interpret/[chainID]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@ import * as React from 'react'
import DecodingForm from './[hash]/form'

export default function TransactionsPlayground({ params }: { params: { chainID: number } }) {
return <DecodingForm currentChainID={params.chainID} />
return <DecodingForm chainID={params.chainID} />
}
2 changes: 1 addition & 1 deletion apps/web/src/app/interpret/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,5 @@ import DecodingForm from './[chainID]/[hash]/form'
import { DEFAULT_CHAIN_ID } from '../data'

export default function TransactionsPlayground() {
return <DecodingForm currentChainID={DEFAULT_CHAIN_ID} />
return <DecodingForm chainID={DEFAULT_CHAIN_ID} />
}
72 changes: 31 additions & 41 deletions packages/transaction-decoder/src/decoding/calldata-decode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ import { isAddress, Hex, getAddress, encodeFunctionData, Address } from 'viem'
import { getProxyStorageSlot } from './proxies.js'
import { AbiParams, AbiStore, ContractAbiResult, getAndCacheAbi, MissingABIError } from '../abi-loader.js'
import * as AbiDecoder from './abi-decode.js'
import { ProxyType, TreeNode } from '../types.js'
import { TreeNode } from '../types.js'
import { PublicClient, RPCFetchError, UnknownNetwork } from '../public-client.js'
import { sameAddress } from '../helpers/address.js'
import { MULTICALL3_ADDRESS, SAFE_MULTISEND_ABI, SAFE_MULTISEND_SIGNATURE } from './constants.js'
import { SAFE_MULTISEND_ABI, SAFE_MULTISEND_SIGNATURE } from './constants.js'

const callDataKeys = ['callData', 'data', '_data']
const addressKeys = ['to', 'target', '_target']

const decodeBytesRecursively = (
node: TreeNode,
Expand All @@ -18,8 +20,6 @@ const decodeBytesRecursively = (
AbiStore<AbiParams, ContractAbiResult> | PublicClient
> =>
Effect.gen(function* () {
const callDataKeys = ['callData', 'data']
const addressKeys = ['to', 'target']
const isCallDataNode =
callDataKeys.includes(node.name) && node.type === 'bytes' && node.value && node.value !== '0x'

Expand Down Expand Up @@ -147,20 +147,19 @@ export const decodeMethod = ({
}) =>
Effect.gen(function* () {
const signature = data.slice(0, 10)
let proxyType: ProxyType | undefined
let implementationAddress: Address | undefined

if (isAddress(contractAddress)) {
//if contract is a proxy, get the implementation address
const implementation = yield* getProxyStorageSlot({ address: getAddress(contractAddress), chainID })

if (implementation) {
contractAddress = implementation.address
proxyType = implementation.type
implementationAddress = implementation.address
}
}

const abi = yield* getAndCacheAbi({
address: contractAddress,
address: implementationAddress ?? contractAddress,
signature,
chainID,
})
Expand All @@ -171,38 +170,6 @@ export const decodeMethod = ({
return yield* new AbiDecoder.DecodeError(`Failed to decode method: ${data}`)
}

//MULTICALL3: decode the params for the multicall3 contract
if (sameAddress(MULTICALL3_ADDRESS, contractAddress) && decoded.params) {
const targetAddress = decoded.params.find((p) => p.name === 'target')?.value as Address | undefined
const decodedParams = yield* Effect.all(
decoded.params.map((p) => decodeBytesRecursively(p, chainID, targetAddress)),
{
concurrency: 'unbounded',
},
)

return {
...decoded,
params: decodedParams,
}
}

//SAFE CONTRACT: decode the params for the safe smart account contract
if (proxyType === 'safe' && decoded.params != null) {
const toAddress = decoded.params.find((p) => p.name === 'to')?.value as Address | undefined
const decodedParams = yield* Effect.all(
decoded.params.map((p) => decodeBytesRecursively(p, chainID, toAddress)),
{
concurrency: 'unbounded',
},
)

return {
...decoded,
params: decodedParams,
}
}

//MULTISEND: decode the params for the multisend contract which is also related to the safe smart account
if (
decoded.signature === SAFE_MULTISEND_SIGNATURE &&
Expand All @@ -216,6 +183,29 @@ export const decodeMethod = ({
}
}

//Attempt to decode the params recursively if they contain data bytes or tuple params
if (decoded.params != null) {
const hasCalldataParam = decoded.params.find((p) => callDataKeys.includes(p.name) && p.type === 'bytes')
const hasTuppleParams = decoded.params.some((p) => p.type === 'tuple')

if (hasCalldataParam || hasTuppleParams) {
const targetAddressParam = decoded.params.find((p) => addressKeys.includes(p.name))
const targetAddress = targetAddressParam?.value as Address | undefined

const decodedParams = yield* Effect.all(
decoded.params.map((p) => decodeBytesRecursively(p, chainID, targetAddress)),
{
concurrency: 'unbounded',
},
)

return {
...decoded,
params: decodedParams,
}
}
}

return decoded
}).pipe(
Effect.withSpan('CalldataDecode.decodeMethod', {
Expand Down
4 changes: 4 additions & 0 deletions packages/transaction-decoder/test/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,10 @@ const OTHER = [
hash: '0x026fdb8b0017ef0e468e6d1627357adb9a8c4b6205ac0049bad80c253c76750c', // Disperse app
chainID: 1,
},
{
hash: '0x36f5c6d053ef3de0a412f871ead797d199d80dbc5ea4ba6ab1b1a211730aea13', //Uniswap Multicall
chainID: 1,
},
] as const

export const TEST_TRANSACTIONS: TXS = [
Expand Down
18 changes: 11 additions & 7 deletions packages/transaction-decoder/test/mocks/abi-loader-mock.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,22 @@
/* eslint-disable turbo/no-undeclared-env-vars */
import { Effect, Layer, Match } from 'effect'
import fs from 'node:fs'
import { AbiStore } from '../../src/abi-loader.js'
// import { FourByteStrategyResolver } from '../../src/effect.js'
// import { EtherscanStrategyResolver } from '../../src/abi-strategy/index.js'
import { FourByteStrategyResolver } from '../../src/effect.js'
import { EtherscanStrategyResolver } from '../../src/abi-strategy/index.js'

export const MockedAbiStoreLive = Layer.succeed(
AbiStore,
AbiStore.of({
strategies: {
default: [
// Run only when adding a new test case
// EtherscanStrategyResolver({ apikey: '' }),
// FourByteStrategyResolver(),
],
default:
process.env.ETHERSCAN_API_KEY != null
? [
// Run only when adding a new test case
EtherscanStrategyResolver({ apikey: process.env.ETHERSCAN_API_KEY! }),
FourByteStrategyResolver(),
]
: [],
},
set: (key, response) =>
Effect.gen(function* () {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
[{"inputs":[{"internalType":"contract ENS","name":"_old","type":"address"}],"payable":false,"stateMutability":"nonpayable","type":"constructor"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"owner","type":"address"},{"indexed":true,"internalType":"address","name":"operator","type":"address"},{"indexed":false,"internalType":"bool","name":"approved","type":"bool"}],"name":"ApprovalForAll","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"bytes32","name":"node","type":"bytes32"},{"indexed":true,"internalType":"bytes32","name":"label","type":"bytes32"},{"indexed":false,"internalType":"address","name":"owner","type":"address"}],"name":"NewOwner","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"bytes32","name":"node","type":"bytes32"},{"indexed":false,"internalType":"address","name":"resolver","type":"address"}],"name":"NewResolver","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"bytes32","name":"node","type":"bytes32"},{"indexed":false,"internalType":"uint64","name":"ttl","type":"uint64"}],"name":"NewTTL","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"bytes32","name":"node","type":"bytes32"},{"indexed":false,"internalType":"address","name":"owner","type":"address"}],"name":"Transfer","type":"event"},{"constant":true,"inputs":[{"internalType":"address","name":"owner","type":"address"},{"internalType":"address","name":"operator","type":"address"}],"name":"isApprovedForAll","outputs":[{"internalType":"bool","name":"","type":"bool"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[],"name":"old","outputs":[{"internalType":"contract ENS","name":"","type":"address"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[{"internalType":"bytes32","name":"node","type":"bytes32"}],"name":"owner","outputs":[{"internalType":"address","name":"","type":"address"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[{"internalType":"bytes32","name":"node","type":"bytes32"}],"name":"recordExists","outputs":[{"internalType":"bool","name":"","type":"bool"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[{"internalType":"bytes32","name":"node","type":"bytes32"}],"name":"resolver","outputs":[{"internalType":"address","name":"","type":"address"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[{"internalType":"address","name":"operator","type":"address"},{"internalType":"bool","name":"approved","type":"bool"}],"name":"setApprovalForAll","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":false,"inputs":[{"internalType":"bytes32","name":"node","type":"bytes32"},{"internalType":"address","name":"owner","type":"address"}],"name":"setOwner","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":false,"inputs":[{"internalType":"bytes32","name":"node","type":"bytes32"},{"internalType":"address","name":"owner","type":"address"},{"internalType":"address","name":"resolver","type":"address"},{"internalType":"uint64","name":"ttl","type":"uint64"}],"name":"setRecord","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":false,"inputs":[{"internalType":"bytes32","name":"node","type":"bytes32"},{"internalType":"address","name":"resolver","type":"address"}],"name":"setResolver","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":false,"inputs":[{"internalType":"bytes32","name":"node","type":"bytes32"},{"internalType":"bytes32","name":"label","type":"bytes32"},{"internalType":"address","name":"owner","type":"address"}],"name":"setSubnodeOwner","outputs":[{"internalType":"bytes32","name":"","type":"bytes32"}],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":false,"inputs":[{"internalType":"bytes32","name":"node","type":"bytes32"},{"internalType":"bytes32","name":"label","type":"bytes32"},{"internalType":"address","name":"owner","type":"address"},{"internalType":"address","name":"resolver","type":"address"},{"internalType":"uint64","name":"ttl","type":"uint64"}],"name":"setSubnodeRecord","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":false,"inputs":[{"internalType":"bytes32","name":"node","type":"bytes32"},{"internalType":"uint64","name":"ttl","type":"uint64"}],"name":"setTTL","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":true,"inputs":[{"internalType":"bytes32","name":"node","type":"bytes32"}],"name":"ttl","outputs":[{"internalType":"uint64","name":"","type":"uint64"}],"payable":false,"stateMutability":"view","type":"function"}]
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"name":"transferFrom","type":"function","stateMutability":"nonpayable","inputs":[{"type":"address"},{"type":"address"},{"type":"address"},{"type":"uint256"}],"outputs":[]}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
[{"inputs":[],"name":"getCurrentBlockTimestamp","outputs":[{"internalType":"uint256","name":"timestamp","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"addr","type":"address"}],"name":"getEthBalance","outputs":[{"internalType":"uint256","name":"balance","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[{"components":[{"internalType":"address","name":"target","type":"address"},{"internalType":"uint256","name":"gasLimit","type":"uint256"},{"internalType":"bytes","name":"callData","type":"bytes"}],"internalType":"struct UniswapInterfaceMulticall.Call[]","name":"calls","type":"tuple[]"}],"name":"multicall","outputs":[{"internalType":"uint256","name":"blockNumber","type":"uint256"},{"components":[{"internalType":"bool","name":"success","type":"bool"},{"internalType":"uint256","name":"gasUsed","type":"uint256"},{"internalType":"bytes","name":"returnData","type":"bytes"}],"internalType":"struct UniswapInterfaceMulticall.Result[]","name":"returnData","type":"tuple[]"}],"stateMutability":"nonpayable","type":"function"}]
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"name":"validateUserOp","type":"function","stateMutability":"nonpayable","inputs":[{"type":"tuple","components":[{"type":"address"},{"type":"uint256"},{"type":"bytes"},{"type":"bytes"},{"type":"uint256"},{"type":"uint256"},{"type":"uint256"},{"type":"uint256"},{"type":"uint256"},{"type":"bytes"},{"type":"bytes"}]},{"type":"bytes32"},{"type":"uint256"}],"outputs":[]}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"name":"isModule","type":"function","stateMutability":"nonpayable","inputs":[{"type":"address"}],"outputs":[]}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"name":"enableTrading","type":"function","stateMutability":"nonpayable","inputs":[],"outputs":[]}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"name":"Transfer","type":"event","inputs":[{"type":"address"},{"type":"address"},{"type":"uint256"}]}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"name":"sendFunds","type":"function","stateMutability":"nonpayable","inputs":[{"type":"address"},{"type":"address"},{"type":"uint256"}],"outputs":[]}
Loading