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
62 changes: 48 additions & 14 deletions app/vibenet/demos/account/components/TransactionModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ import {
conciseError,
EstimateRevertedError,
TxPendingError,
TxRevertedError,
useAccountEngine,
} from '../useAccountEngine';

Expand Down Expand Up @@ -155,7 +156,7 @@ export function TransactionModal({ onClose, preset }: TransactionModalProps) {
const [estimateBlocked, setEstimateBlocked] = useState<string | null>(null);
const [payerRetry, setPayerRetry] = useState<PayerRetry | null>(null);
// Error UI is local to this dialog — nothing leaks onto the page behind it.
const [error, setError] = useState('');
const [error, setError] = useState<DialogError | null>(null);
const [txStep, setTxStep] = useState<'build' | 'review' | 'submitted'>(
preset?.calls ? 'review' : 'build',
);
Expand Down Expand Up @@ -218,13 +219,21 @@ export function TransactionModal({ onClose, preset }: TransactionModalProps) {
});
};

const surfaceSendError = (message: string) => {
setError(message);
toast.error(message);
const surfaceSendError = (message: string, txHash?: Hex) => {
setError({ message, txHash });
toast.error(txHash ? `${message} (${txHash}).` : message);
};

const sendError = (err: unknown) => {
if (err instanceof EstimateRevertedError) setEstimateBlocked(err.reason);
if (err instanceof TxRevertedError) {
surfaceSendError('Transaction reverted onchain', err.txHash);
return;
}
if (err instanceof EstimateRevertedError) {
setEstimateBlocked(err.reason);
surfaceSendError(err.reason);
return;
}
const e = err as { message?: string; name?: string };
surfaceSendError(conciseError(e.message ?? String(err)));
};
Expand Down Expand Up @@ -364,7 +373,7 @@ export function TransactionModal({ onClose, preset }: TransactionModalProps) {
const confirmSend = async (forceEstimate = false, retry: PayerRetry | null = null) => {
const mode = retry?.kind === 'usdv' ? 'usdv' : gasMode;
if (retry?.kind === 'usdv') setGasMode('usdv');
setError('');
setError(null);
setEstimateBlocked(null);
setPayerRetry(null);
setTxStep('submitted');
Expand Down Expand Up @@ -404,7 +413,7 @@ export function TransactionModal({ onClose, preset }: TransactionModalProps) {

const startSend = () => {
if (!callsValid || !activeSigner) return;
setError('');
setError(null);
setResult(null);
setTxStep('review');
};
Expand Down Expand Up @@ -436,12 +445,15 @@ export function TransactionModal({ onClose, preset }: TransactionModalProps) {
size="sm"
onClick={() => {
setTxStep('review');
setError('');
setError(null);
setPayerRetry(null);
}}
>
Back
</Button>
{error.txHash ? (
<ViewTransactionButton href={`${VIBENET_EXPLORER_PATH}/tx/${error.txHash}`} />
) : null}
<Button variant="primary" size="sm" onClick={() => confirmSend(Boolean(estimateBlocked), payerRetry)}>
{retryLabel}
</Button>
Expand All @@ -461,7 +473,7 @@ export function TransactionModal({ onClose, preset }: TransactionModalProps) {
size="sm"
onClick={() => {
setTxStep('build');
setError('');
setError(null);
}}
disabled={signing}
>
Expand Down Expand Up @@ -867,7 +879,7 @@ type ReviewBodyProps = {
gasEstimate: number;
nonceless: boolean;
txSigner: WalletSigner | null;
error: string;
error: DialogError | null;
};

function ReviewBody({
Expand Down Expand Up @@ -943,7 +955,7 @@ function ReviewBody({
{error ? (
<div className="flex items-start gap-2 py-1 text-[13px] text-bds-red-60 [line-break:anywhere]">
<ErrorGlyph />
{error}
<ErrorCopy error={error} />
</div>
) : (
<div className="flex flex-wrap items-center justify-between gap-3">
Expand Down Expand Up @@ -991,7 +1003,7 @@ function SubmittedBody({
}: {
signing: boolean;
submitStatus: '' | 'submitting' | 'confirming';
error: string;
error: DialogError | null;
result: SubmittedResult;
}) {
if (signing) {
Expand All @@ -1013,8 +1025,11 @@ function SubmittedBody({
return (
<div className="flex flex-col items-center gap-3 py-10 text-center">
<ErrorGlyph size={28} />
<Text variant="label.regular" className="text-bds-red-60 [line-break:anywhere]">
{error}
<Text
variant="label.regular"
className={cn('max-w-full text-bds-red-60', !error.txHash && '[line-break:anywhere]')}
>
<ErrorCopy error={error} />
</Text>
</div>
);
Expand Down Expand Up @@ -1057,6 +1072,25 @@ function SubmittedBody({
);
}

type DialogError = { message: string; txHash?: Hex };

function ErrorCopy({ error }: { error: DialogError }) {
if (!error.txHash) return error.message;
return (
<span className="flex max-w-full flex-col items-center gap-1.5">
<span>{error.message}</span>
<a
href={`${VIBENET_EXPLORER_PATH}/tx/${error.txHash}`}
target="_blank"
rel="noreferrer"
className="max-w-full overflow-x-auto font-mono text-[13px] whitespace-nowrap text-base-blue hover:underline"
>
{error.txHash}
</a>
</span>
);
}

function ErrorGlyph({ size = 16 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 40 40" fill="none" className="mt-px shrink-0 text-bds-red-60" aria-hidden="true">
Expand Down
100 changes: 100 additions & 0 deletions app/vibenet/demos/account/library/revertReason.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { encodeErrorResult } from 'viem';
import { describe, expect, it } from 'vitest';

import { revertReason } from './revertReason';

const abi = [
{ type: 'error', name: 'Error', inputs: [{ name: 'message', type: 'string' }] },
{
type: 'error',
name: 'ERC20InsufficientBalance',
inputs: [
{ name: 'sender', type: 'address' },
{ name: 'balance', type: 'uint256' },
{ name: 'needed', type: 'uint256' },
],
},
{
type: 'error',
name: 'ERC20InsufficientAllowance',
inputs: [
{ name: 'spender', type: 'address' },
{ name: 'allowance', type: 'uint256' },
{ name: 'needed', type: 'uint256' },
],
},
] as const;

const balanceData = encodeErrorResult({
abi,
errorName: 'Error',
args: ['ERC20: transfer amount exceeds balance'],
});
const customBalance = encodeErrorResult({
abi,
errorName: 'ERC20InsufficientBalance',
args: ['0x0000000000000000000000000000000000000001', 10n, 50n],
});
const customAllowance = encodeErrorResult({
abi,
errorName: 'ERC20InsufficientAllowance',
args: ['0x0000000000000000000000000000000000000001', 0n, 1n],
});

describe('revertReason', () => {
it('reads an ERC-20 balance revert out of the node message viem hides', () => {
const err = {
shortMessage: 'RPC Request failed.',
details: 'execution reverted: ERC20: transfer amount exceeds balance',
message: 'RPC Request failed.\n\nDetails: execution reverted: ERC20: transfer amount exceeds balance\nVersion: viem@2.0.0',
};
expect(revertReason(err)).toBe('Not enough ERC-20 balance for this transfer.');
});

it('decodes Error(string) revert data when the node message has no reason', () => {
const err = {
shortMessage: 'RPC Request failed.',
details: 'execution reverted',
data: balanceData,
cause: { code: 3, message: 'execution reverted', data: balanceData },
};
expect(revertReason(err)).toBe('Not enough ERC-20 balance for this transfer.');
});

it('decodes the ERC-20 custom balance and allowance errors', () => {
expect(revertReason({ shortMessage: 'RPC Request failed.', details: 'execution reverted', data: customBalance })).toBe(
'Not enough ERC-20 balance for this transfer.',
);
expect(revertReason({ shortMessage: 'RPC Request failed.', data: customAllowance })).toBe(
'Not enough ERC-20 allowance for this transfer.',
);
});

it('keeps an unrecognized revert string', () => {
expect(revertReason({ details: 'execution reverted: USDV: transfers are paused' })).toBe(
'This transaction would revert: USDV: transfers are paused.',
);
});

it('names an ETH balance failure', () => {
expect(revertReason({ details: 'insufficient funds for gas * price + value' })).toBe(
'Not enough ETH to cover this transaction.',
);
});

it('reads the vibenet USDV revert, whose reason is the string "balance"', () => {
const err = {
shortMessage: 'RPC Request failed.',
details: 'execution reverted: balance',
data: '0x08c379a00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000762616c616e636500000000000000000000000000000000000000000000000000',
cause: { code: 3, message: 'execution reverted: balance' },
};
expect(revertReason(err)).toBe('Not enough ERC-20 balance for this transfer.');
});

it('falls back when the only text is the RPC wrapper', () => {
expect(revertReason({ shortMessage: 'RPC Request failed.', message: 'RPC Request failed.' })).toBe(
'This transaction would revert.',
);
});
});
141 changes: 141 additions & 0 deletions app/vibenet/demos/account/library/revertReason.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
// Turn a viem/RPC gas-estimate failure into the reason a person can act on.
// `eth_estimateGas` throws viem's `RpcRequestError`, whose short message is
// always "RPC Request failed." The node's revert string and revert data sit
// on `details` / `data` (and on `cause`).

import { decodeAbiParameters, type Hex } from '@aa';

const ERROR_STRING = '0x08c379a0';
const INSUFFICIENT_BALANCE = '0xe450d38c';
const INSUFFICIENT_ALLOWANCE = '0xfb8f41b2';

const BOILERPLATE =
/^(rpc request failed\.?|http request failed\.?|missing or invalid parameters\.?|an unknown rpc error occurred\.?|an unknown error occurred\.?|execution reverted(?: for an unknown reason)?\.?|the request took too long to respond\.?)$/i;

const FALLBACK = 'This transaction would revert.';

type ErrLike = {
shortMessage?: unknown;
details?: unknown;
message?: unknown;
data?: unknown;
cause?: unknown;
};

/** User-facing explanation of a reverting gas estimate. */
export function revertReason(err: unknown): string {
const decoded = decodeRevertData(collectRevertData(err));
if (decoded) return decoded;
for (const text of collectTexts(err)) {
const human = humanizeRevert(text);
if (human !== FALLBACK) return human;
}
return FALLBACK;
}

function humanizeRevert(raw: string): string {
const text = raw
.replace(/^execution reverted:?\s*/i, '')
.replace(/:\s*0x[0-9a-fA-F]+$/, '')
.trim()
.replace(/\.$/, '');
if (!text || BOILERPLATE.test(text) || /^0x[0-9a-fA-F]+$/.test(text)) return FALLBACK;
// Vibenet's USDV token reverts with the bare string "balance". Other ERC-20s
// use the longer OpenZeppelin phrases or the custom error.
if (
/^(erc-?20:\s*)?balance$|transfer amount exceeds balance|insufficient balance|erc20insufficientbalance/i.test(
text,
)
) {
return 'Not enough ERC-20 balance for this transfer.';
}
if (/insufficient allowance|transfer amount exceeds allowance|erc20insufficientallowance/i.test(text)) {
return 'Not enough ERC-20 allowance for this transfer.';
}
if (/insufficient funds/i.test(text)) {
return 'Not enough ETH to cover this transaction.';
}
return `This transaction would revert: ${text}.`;
}

function decodeRevertData(blobs: Hex[]): string | null {
for (const data of blobs) {
const selector = data.slice(0, 10).toLowerCase();
const payload = `0x${data.slice(10)}` as Hex;
try {
if (selector === ERROR_STRING) {
const [message] = decodeAbiParameters([{ type: 'string' }], payload);
if (typeof message === 'string' && message.trim()) return humanizeRevert(message);
} else if (selector === INSUFFICIENT_BALANCE) {
decodeAbiParameters([{ type: 'address' }, { type: 'uint256' }, { type: 'uint256' }], payload);
return 'Not enough ERC-20 balance for this transfer.';
} else if (selector === INSUFFICIENT_ALLOWANCE) {
decodeAbiParameters([{ type: 'address' }, { type: 'uint256' }, { type: 'uint256' }], payload);
return 'Not enough ERC-20 allowance for this transfer.';
}
} catch {
/* This blob is not that error. Try the next one. */
}
}
return null;
}

function collectRevertData(err: unknown): Hex[] {
const found: Hex[] = [];
walk(err, (node) => {
pushHex(found, node.data);
if (node.data && typeof node.data === 'object' && node.data !== null && 'data' in node.data) {
pushHex(found, (node.data as { data?: unknown }).data);
}
// Some nodes append the revert blob to the message instead of `data`.
// Ignore other hex in the dump (calldata, addresses) so it can't be
// mistaken for the revert.
for (const value of [node.details, node.message]) pushHex(found, revertHex(value));
});
return found;
}

function collectTexts(err: unknown): string[] {
const found: string[] = [];
walk(err, (node) => {
for (const value of [node.details, node.message, node.shortMessage]) {
const text = cleanText(value);
if (text && !found.includes(text)) found.push(text);
}
});
return found;
}

function cleanText(value: unknown): string | null {
if (typeof value !== 'string') return null;
const detail = value.match(/Details:\s*([\s\S]*?)(?:\n\s*Version:|\s*Version:\s|$)/);
const text = (detail?.[1] ?? value.split('\n').map((line) => line.trim()).find(Boolean) ?? '').trim();
if (!text || BOILERPLATE.test(text)) return null;
return text;
}

function pushHex(found: Hex[], value: unknown) {
if (typeof value !== 'string' || !/^0x[0-9a-fA-F]{8,}$/.test(value)) return;
if ((value.length - 2) % 2 !== 0) return;
const hex = value as Hex;
if (!found.includes(hex)) found.push(hex);
}

function revertHex(value: unknown): string | null {
if (typeof value !== 'string') return null;
return value.match(/execution reverted:?\s*(0x[0-9a-fA-F]{8,})/i)?.[1] ?? null;
}

function walk(err: unknown, visit: (node: ErrLike) => void) {
const seen = new Set<unknown>();
const queue = [err];
while (queue.length > 0) {
const node = queue.shift();
if (!node || typeof node !== 'object' || seen.has(node)) continue;
seen.add(node);
const record = node as ErrLike;
visit(record);
if (record.cause && typeof record.cause === 'object') queue.push(record.cause);
if (record.data && typeof record.data === 'object') queue.push(record.data);
}
}
Loading
Loading