diff --git a/app/vibenet/explorer/tx/[hash]/page.tsx b/app/vibenet/explorer/tx/[hash]/page.tsx index 7237aa1..626773d 100644 --- a/app/vibenet/explorer/tx/[hash]/page.tsx +++ b/app/vibenet/explorer/tx/[hash]/page.tsx @@ -11,6 +11,7 @@ import { ExplorerLink } from '../../../components/ExplorerLink'; import type { ExplorerAaCall, ExplorerTxLog, ExplorerTxResponse } from '../../../library/api-types'; import { vibenetApi, VibenetApiError } from '../../../library/client'; import type { DecodedB20MemoCall, DecodedCall } from '../../../library/explorer'; +import { fetchTokenMeta, type TokenMeta } from '../../../library/token'; import { callSelector, decodeB20MemoCalldata, @@ -21,6 +22,7 @@ import { decodeExecuteBatch, decodeMetadata, EXECUTE_BATCH_SELECTOR, + findGasTokenFee, fmtHexInt, fmtTokenAmount, hexToInt, @@ -365,6 +367,22 @@ function TxBody({ tx }: TxBodyProps) { const hasMetadata = Boolean(tx.metadata && tx.metadata !== '0x'); const selector = tx.input && tx.input.length >= 10 ? tx.input.slice(0, 10) : null; const b20Memo = tx.isAa ? null : decodeB20MemoCalldata(tx.input); + const gasTokenFee = tx.isAa ? findGasTokenFee(tx.payer, tx.aa) : null; + const gasTokenAddress = gasTokenFee?.token ?? null; + const [gasTokenMeta, setGasTokenMeta] = useState(null); + + useEffect(() => { + setGasTokenMeta(null); + if (!gasTokenAddress) return; + let cancelled = false; + fetchTokenMeta(gasTokenAddress).then((meta) => { + if (!cancelled) setGasTokenMeta(meta); + }); + return () => { + cancelled = true; + }; + }, [gasTokenAddress]); + const inputBytes = tx.input && tx.input !== '0x' ? (tx.input.length - 2) / 2 : 0; const callCount = (tx.aa?.calls ?? []).reduce((sum, phase) => sum + phase.length, 0); const phaseCount = tx.aa?.calls.length ?? 0; @@ -465,7 +483,18 @@ function TxBody({ tx }: TxBodyProps) { ) : null} {!tx.isAa ? {weiToEth(tx.value)} : null} {nonceBody} - {tx.fee ? {weiToEth(tx.fee)} : null} + {tx.fee ? ( + gasTokenFee ? ( + // Paid in the gas token instead — labeling this "Fee" would read + // as what the user paid, but it's still useful for debugging the + // payer's economics (does the flat token fee cover this?). + + {weiToEth(tx.fee)} (paid by payer) + + ) : ( + {weiToEth(tx.fee)} + ) + ) : null} {fmtHexInt(tx.gas)} {tx.gasUsed ? ( @@ -476,6 +505,26 @@ function TxBody({ tx }: TxBodyProps) { {tx.effectiveGasPrice ? ( {weiToGwei(tx.effectiveGasPrice)} ) : null} + {gasTokenFee ? ( + + {gasTokenMeta ? ( + + {fmtTokenAmount(gasTokenFee.rawAmount, gasTokenMeta.decimals)} {gasTokenMeta.symbol} + + ) : ( + + {gasTokenFee.rawAmount.toString()}{' '} + raw units + + )}{' '} + + + ) : null} {hasMetadata ? ( {memo ? ( diff --git a/app/vibenet/library/explorer.test.ts b/app/vibenet/library/explorer.test.ts index 6afa2f3..c2dfe18 100644 --- a/app/vibenet/library/explorer.test.ts +++ b/app/vibenet/library/explorer.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { encodeAbiParameters, encodeFunctionData, padHex, stringToHex } from 'viem'; +import type { ExplorerAaCall, ExplorerAaPayload } from './api-types'; import { B20_ANNOUNCEMENT_TOPIC, B20_END_ANNOUNCEMENT_TOPIC, @@ -10,6 +11,7 @@ import { decodeB20MemoCalldata, decodeB20MemoEvent, decodeExecuteBatch, + findGasTokenFee, fmtHexInt, fmtTokenAmount, hexToInt, @@ -32,6 +34,9 @@ const memoAbi = [ const batchAbi = [ { type: 'function', name: 'executeBatch', stateMutability: 'nonpayable', inputs: [{ type: 'tuple[]', components: [{ type: 'address' }, { type: 'uint256' }, { type: 'bytes' }] }], outputs: [] }, ] as const; +const erc20Abi = [ + { type: 'function', name: 'transfer', stateMutability: 'nonpayable', inputs: [{ type: 'address' }, { type: 'uint256' }], outputs: [{ type: 'bool' }] }, +] as const; const ADDR_A = '0x1111111111111111111111111111111111111111'; const ADDR_B = '0x2222222222222222222222222222222222222222'; @@ -272,3 +277,46 @@ describe('B20 announcement event decoding', () => { }); }); }); + +describe('gas token fee detection', () => { + const PAYER = ADDR_B; + const TOKEN = '0x3333333333333333333333333333333333333333'; + + function aaWith(calls: ExplorerAaCall[][]): ExplorerAaPayload { + return { + sender: ADDR_A, + nonceKey: '0x0', + nonceSequence: '0x0', + expiry: '0x0', + maxFeePerGas: null, + maxPriorityFeePerGas: null, + calls, + accountChanges: [], + }; + } + + it('picks up a phase-0 transfer(payer, fee) as the gas token fee', () => { + const data = encodeFunctionData({ abi: erc20Abi, functionName: 'transfer', args: [PAYER, 100_000_000n] }); + const aa = aaWith([[{ to: TOKEN, value: '0x0', data }], [{ to: ADDR_A, value: '0x0', data: '0x' }]]); + + expect(findGasTokenFee(PAYER, aa)).toEqual({ token: TOKEN.toLowerCase(), rawAmount: 100_000_000n }); + }); + + it('returns null when self-paying (no aa payload / payer)', () => { + const data = encodeFunctionData({ abi: erc20Abi, functionName: 'transfer', args: [PAYER, 1n] }); + const aa = aaWith([[{ to: TOKEN, value: '0x0', data }]]); + + expect(findGasTokenFee(null, aa)).toBeNull(); + expect(findGasTokenFee(PAYER, null)).toBeNull(); + }); + + it('ignores a phase 0 that is not a lone transfer to the payer', () => { + const data = encodeFunctionData({ abi: erc20Abi, functionName: 'transfer', args: [ADDR_A, 1n] }); + const wrongRecipient = aaWith([[{ to: TOKEN, value: '0x0', data }]]); + expect(findGasTokenFee(PAYER, wrongRecipient)).toBeNull(); + + const feeData = encodeFunctionData({ abi: erc20Abi, functionName: 'transfer', args: [PAYER, 1n] }); + const multiCallPhase0 = aaWith([[{ to: TOKEN, value: '0x0', data: feeData }, { to: ADDR_A, value: '0x0', data: '0x' }]]); + expect(findGasTokenFee(PAYER, multiCallPhase0)).toBeNull(); + }); +}); diff --git a/app/vibenet/library/explorer.ts b/app/vibenet/library/explorer.ts index d695845..2e12f85 100644 --- a/app/vibenet/library/explorer.ts +++ b/app/vibenet/library/explorer.ts @@ -4,7 +4,7 @@ import { decodeAbiParameters, type Hex } from 'viem'; -import type { ExplorerTxLog } from './api-types'; +import type { ExplorerAaPayload, ExplorerTxLog } from './api-types'; // --- Time ----------------------------------------------------------------- @@ -384,6 +384,33 @@ export function decodeErc20TransferCalldata( } } +export type GasTokenFee = { + /** The ERC-20 contract the fee was paid in. */ + token: string; + rawAmount: bigint; +}; + +/** + * The b20 demo pays gas by having the AA account transfer(payer, fee) as + * phase 0 of the batch (see useAccountEngine's sendActiveCalls) instead of + * the payer deducting it from a receipt field — there is no dedicated gas + * field for this. Detect that pattern: phase 0's sole call is an ERC-20 + * transfer to `payer`. + */ +export function findGasTokenFee( + payer: string | null, + aa: ExplorerAaPayload | null, +): GasTokenFee | null { + if (!payer || !aa) return null; + const phase0 = aa.calls[0]; + if (!phase0 || phase0.length !== 1) return null; + const [call] = phase0; + if (!call.to) return null; + const transfer = decodeErc20TransferCalldata(call.data); + if (!transfer || transfer.recipient.toLowerCase() !== payer.toLowerCase()) return null; + return { token: call.to, rawAmount: transfer.rawAmount }; +} + export type DecodedB20MemoCall = { operation: 'transferWithMemo' | 'transferFromWithMemo' | 'mintWithMemo' | 'burnWithMemo'; from?: string; diff --git a/app/vibenet/library/token.ts b/app/vibenet/library/token.ts new file mode 100644 index 0000000..ce9a419 --- /dev/null +++ b/app/vibenet/library/token.ts @@ -0,0 +1,31 @@ +// Best-effort on-chain ERC-20 metadata lookup for the explorer. The tx +// receipt has no symbol/decimals for an arbitrary token (the b20 demo lets +// users deploy their own), so resolving a display name means reading the +// contract directly. + +import { createPublicClient, http, type Address as ViemAddress } from 'viem'; + +import { VIBENET_RPC_URL } from './config'; + +const ERC20_METADATA_ABI = [ + { type: 'function', name: 'symbol', stateMutability: 'view', inputs: [], outputs: [{ type: 'string' }] }, + { type: 'function', name: 'decimals', stateMutability: 'view', inputs: [], outputs: [{ type: 'uint8' }] }, +] as const; + +const client = createPublicClient({ transport: http(VIBENET_RPC_URL) }); + +export type TokenMeta = { symbol: string; decimals: number }; + +/** Reads `symbol()`/`decimals()`; null if the address isn't a readable ERC-20. */ +export async function fetchTokenMeta(address: string): Promise { + try { + const addr = address as ViemAddress; + const [symbol, decimals] = await Promise.all([ + client.readContract({ address: addr, abi: ERC20_METADATA_ABI, functionName: 'symbol' }), + client.readContract({ address: addr, abi: ERC20_METADATA_ABI, functionName: 'decimals' }), + ]); + return { symbol, decimals }; + } catch { + return null; + } +}