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
51 changes: 50 additions & 1 deletion app/vibenet/explorer/tx/[hash]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -21,6 +22,7 @@ import {
decodeExecuteBatch,
decodeMetadata,
EXECUTE_BATCH_SELECTOR,
findGasTokenFee,
fmtHexInt,
fmtTokenAmount,
hexToInt,
Expand Down Expand Up @@ -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<TokenMeta | null>(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;
Expand Down Expand Up @@ -465,7 +483,18 @@ function TxBody({ tx }: TxBodyProps) {
) : null}
{!tx.isAa ? <DetailRow label="Value">{weiToEth(tx.value)}</DetailRow> : null}
<DetailRow label="Nonce">{nonceBody}</DetailRow>
{tx.fee ? <DetailRow label="Fee">{weiToEth(tx.fee)}</DetailRow> : 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?).
<DetailRow label="Network cost">
<span className={DIM}>{weiToEth(tx.fee)} (paid by payer)</span>
</DetailRow>
) : (
<DetailRow label="Fee">{weiToEth(tx.fee)}</DetailRow>
)
) : null}
<DetailRow label="Gas limit">{fmtHexInt(tx.gas)}</DetailRow>
{tx.gasUsed ? (
<DetailRow label="Gas used">
Expand All @@ -476,6 +505,26 @@ function TxBody({ tx }: TxBodyProps) {
{tx.effectiveGasPrice ? (
<DetailRow label="Effective gas price">{weiToGwei(tx.effectiveGasPrice)}</DetailRow>
) : null}
{gasTokenFee ? (
<DetailRow label="Gas token fee">
{gasTokenMeta ? (
<span>
{fmtTokenAmount(gasTokenFee.rawAmount, gasTokenMeta.decimals)} {gasTokenMeta.symbol}
</span>
) : (
<span>
<code className="font-mono">{gasTokenFee.rawAmount.toString()}</code>{' '}
<span className={DIM}>raw units</span>
</span>
)}{' '}
<ExplorerLink
kind="address"
value={gasTokenFee.token}
label={gasTokenMeta ? gasTokenMeta.symbol : gasTokenFee.token}
className="break-all"
/>
</DetailRow>
) : null}
{hasMetadata ? (
<DetailRow label="Metadata">
{memo ? (
Expand Down
48 changes: 48 additions & 0 deletions app/vibenet/library/explorer.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -10,6 +11,7 @@ import {
decodeB20MemoCalldata,
decodeB20MemoEvent,
decodeExecuteBatch,
findGasTokenFee,
fmtHexInt,
fmtTokenAmount,
hexToInt,
Expand All @@ -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';
Expand Down Expand Up @@ -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();
});
});
29 changes: 28 additions & 1 deletion app/vibenet/library/explorer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

import { decodeAbiParameters, type Hex } from 'viem';

import type { ExplorerTxLog } from './api-types';
import type { ExplorerAaPayload, ExplorerTxLog } from './api-types';

// --- Time -----------------------------------------------------------------

Expand Down Expand Up @@ -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;
Expand Down
31 changes: 31 additions & 0 deletions app/vibenet/library/token.ts
Original file line number Diff line number Diff line change
@@ -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<TokenMeta | null> {
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;
}
}
Loading