Skip to content

Commit 1331dfe

Browse files
authored
Merge pull request #43 from szhygulin/feat/tron-preflight-status
TRON: pre-flight simulation, status polling, verify-decode preview
2 parents fa26c71 + 6c6a84b commit 1331dfe

10 files changed

Lines changed: 403 additions & 23 deletions

File tree

src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -904,7 +904,7 @@ async function main() {
904904
"get_transaction_status",
905905
{
906906
description:
907-
"Poll a transaction's status via the chain's RPC. Returns pending / success / failed, or unknown if the node hasn't seen it yet.",
907+
"Poll a transaction's status via the chain's RPC (EVM) or TronGrid (TRON). Returns pending / success / failed, or unknown if the node hasn't seen it yet. Pass chain='tron' with the bare hex txID for TRON.",
908908
inputSchema: getTransactionStatusInput.shape,
909909
},
910910
handler(getTransactionStatus)

src/modules/execution/index.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
tronPathForAccountIndex,
2323
} from "../../signing/tron-usb-signer.js";
2424
import { broadcastTronTx } from "../tron/broadcast.js";
25+
import { getTronTransactionStatus } from "../tron/status.js";
2526
import { assertTransactionSafe } from "../../signing/pre-sign-check.js";
2627
import {
2728
eip1559PreSignHash,
@@ -673,6 +674,9 @@ export async function sendTransaction(args: SendTransactionArgs): Promise<{
673674
}
674675

675676
export async function getTransactionStatus(args: GetTransactionStatusArgs) {
677+
if (args.chain === "tron") {
678+
return getTronTransactionStatus(args.txHash);
679+
}
676680
const client = getClient(args.chain as SupportedChain);
677681
try {
678682
const receipt = await client.getTransactionReceipt({ hash: args.txHash as `0x${string}` });
@@ -750,7 +754,10 @@ export function getTxVerification(args: GetTxVerificationArgs): UnsignedTx | Uns
750754
* client-rendered SPA output. One MCP tool = one auditable code path.
751755
*/
752756
export async function verifyTxDecode(args: GetTxVerificationArgs): Promise<VerifyDecodeResult> {
753-
if (hasTronHandle(args.handle)) return notApplicableForTron();
757+
if (hasTronHandle(args.handle)) {
758+
const tronTx = consumeTronHandle(args.handle);
759+
return notApplicableForTron(tronTx);
760+
}
754761
if (!hasHandle(args.handle)) {
755762
throw new Error(
756763
`Unknown or expired tx handle '${args.handle}'. Prepared transactions live for ` +

src/modules/execution/schemas.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,8 +137,15 @@ export const previewSendInput = z.object({
137137
});
138138

139139
export const getTransactionStatusInput = z.object({
140-
chain: chainEnum,
141-
txHash: z.string().regex(/^0x[a-fA-F0-9]{64}$/),
140+
chain: z
141+
.enum([...SUPPORTED_CHAINS, "tron"] as unknown as [string, ...string[]])
142+
.describe("EVM chain or 'tron'."),
143+
txHash: z
144+
.string()
145+
.regex(/^(0x)?[a-fA-F0-9]{64}$/)
146+
.describe(
147+
"32-byte tx hash as hex. EVM txs are conventionally 0x-prefixed; TRON tx IDs are bare hex — both are accepted."
148+
),
142149
});
143150

144151
export const getTxVerificationInput = z.object({

src/modules/tron/actions.ts

Lines changed: 130 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,112 @@ interface TrongridTriggerResponse {
9494
};
9595
}
9696

97+
interface TrongridConstantResponse {
98+
result?: { result?: boolean; message?: string; code?: string };
99+
energy_used?: number;
100+
constant_result?: string[];
101+
}
102+
103+
/**
104+
* Mainnet energy price in sun-per-energy. Hardcoded at the October 2024
105+
* governance value (420 sun/energy). If governance changes it, the estimate
106+
* drifts; fee_limit (the cap) is still enforced by the network, so drift
107+
* here just affects the preview string. A dynamic read via
108+
* /wallet/getchainparameters is possible but not worth the extra round-trip
109+
* for a preview-only number.
110+
*/
111+
const ENERGY_PRICE_SUN = 420n;
112+
113+
/** Well-known solidity revert selector: Error(string) = 0x08c379a0. */
114+
const ERROR_STRING_SELECTOR = "08c379a0";
115+
116+
/**
117+
* Decode the revert payload from a triggerconstantcontract constant_result.
118+
* The network returns ABI-encoded revert data: 4-byte Error(string) selector
119+
* plus an ABI-encoded string. We crudely extract the string bytes without
120+
* pulling in viem — this helper lives on the TRON path which is otherwise
121+
* viem-free.
122+
*/
123+
function decodeRevertString(constantResult: string[] | undefined): string | undefined {
124+
if (!constantResult || constantResult.length === 0) return undefined;
125+
const hex = constantResult[0].replace(/^0x/, "");
126+
if (!hex.startsWith(ERROR_STRING_SELECTOR)) return undefined;
127+
const body = hex.slice(ERROR_STRING_SELECTOR.length);
128+
// body = 32-byte offset (ignored) + 32-byte length + string bytes padded to 32.
129+
if (body.length < 128) return undefined;
130+
const lengthHex = body.slice(64, 128);
131+
const length = parseInt(lengthHex, 16);
132+
if (!Number.isFinite(length) || length <= 0 || length > body.length / 2) return undefined;
133+
const stringHex = body.slice(128, 128 + length * 2);
134+
try {
135+
return Buffer.from(stringHex, "hex").toString("utf8");
136+
} catch {
137+
return undefined;
138+
}
139+
}
140+
141+
/**
142+
* Dry-run a smart-contract call via /wallet/triggerconstantcontract. This is
143+
* TRON's eth_call analogue — it executes the call against current state
144+
* without building a broadcastable tx. We use it as a pre-flight before
145+
* /wallet/triggersmartcontract so we refuse to hand out a handle for a tx
146+
* that would revert on-chain (insufficient balance, paused token, blocked
147+
* recipient). Returns the energy estimate for the preview.
148+
*/
149+
async function preflightConstantContract(
150+
body: Record<string, unknown>,
151+
apiKey: string | undefined
152+
): Promise<{ energyUsed: bigint }> {
153+
const res = await trongridPost<TrongridConstantResponse>(
154+
"/wallet/triggerconstantcontract",
155+
body,
156+
apiKey
157+
);
158+
if (res.result?.result === false) {
159+
throw new Error(
160+
`TronGrid pre-flight rejected the call: ${res.result.message ?? "unknown validation error"}`
161+
);
162+
}
163+
const revert = decodeRevertString(res.constant_result);
164+
if (revert) {
165+
throw new Error(
166+
`TronGrid pre-flight reverted: ${revert}. This tx would fail on-chain — refusing to prepare a handle.`
167+
);
168+
}
169+
const energyUsed = BigInt(res.energy_used ?? 0);
170+
return { energyUsed };
171+
}
172+
173+
interface TrongridGetAccountResponse {
174+
latest_withdraw_time?: number;
175+
}
176+
177+
const CLAIM_COOLDOWN_MS = 24 * 60 * 60 * 1000;
178+
179+
async function readClaimCooldownRemaining(
180+
owner: string,
181+
apiKey: string | undefined
182+
): Promise<number | null> {
183+
const res = await trongridPost<TrongridGetAccountResponse>(
184+
"/wallet/getaccount",
185+
{ address: owner, visible: true },
186+
apiKey
187+
);
188+
const last = res.latest_withdraw_time;
189+
if (!last) return null;
190+
const elapsed = Date.now() - last;
191+
if (elapsed >= CLAIM_COOLDOWN_MS) return 0;
192+
return CLAIM_COOLDOWN_MS - elapsed;
193+
}
194+
195+
function formatDuration(ms: number): string {
196+
const totalMin = Math.ceil(ms / 60_000);
197+
const h = Math.floor(totalMin / 60);
198+
const m = totalMin % 60;
199+
if (h === 0) return `${m}m`;
200+
return `${h}h ${m}m`;
201+
}
202+
97203
// ----- Native TRX send -----
98204

99205
export interface BuildTronNativeSendArgs {
@@ -216,6 +322,12 @@ export async function buildTronTokenSend(
216322
visible: true,
217323
};
218324
const apiKey = resolveTronApiKey(readUserConfig());
325+
// Pre-flight dry-run via triggerconstantcontract. Catches the broad class of
326+
// prepare-succeeds-then-broadcast-reverts failures: insufficient token
327+
// balance, USDT blocklist, paused contract. Also gives us the energy
328+
// estimate for the preview (vs. the fee_limit cap).
329+
const { energyUsed } = await preflightConstantContract(body, apiKey);
330+
const estimatedEnergySun = energyUsed * ENERGY_PRICE_SUN;
219331
const res = await trongridPost<TrongridTriggerResponse>(
220332
"/wallet/triggersmartcontract",
221333
body,
@@ -258,6 +370,8 @@ export async function buildTronTokenSend(
258370
},
259371
},
260372
feeLimitSun: feeLimitSun.toString(),
373+
estimatedEnergyUsed: energyUsed.toString(),
374+
estimatedEnergyCostSun: estimatedEnergySun.toString(),
261375
};
262376
return issueTronHandle(tx);
263377
}
@@ -559,8 +673,22 @@ export async function buildTronClaimRewards(
559673
apiKey
560674
);
561675
if (res.Error) {
562-
// Common case: "WithdrawBalance not allowed, need 24 hours since last Withdraw"
563-
// — TRON enforces a 24h rate limit on claims. Surface TronGrid's message verbatim.
676+
// Most common failure: TRON's 24h-between-claims rate limit. TronGrid
677+
// returns "WithdrawBalance not allowed, need 24 hours since last Withdraw"
678+
// without telling you when the cooldown expires. Read the account's
679+
// latest_withdraw_time and translate to "claim again in X hours Y min"
680+
// so the user doesn't have to guess.
681+
if (/24 hours since last Withdraw/i.test(res.Error)) {
682+
const remaining = await readClaimCooldownRemaining(args.from, apiKey).catch(
683+
() => null
684+
);
685+
if (remaining !== null) {
686+
throw new Error(
687+
`TRON claim cooldown active — last claim was less than 24h ago. ` +
688+
`Next claim available in ${formatDuration(remaining)}.`
689+
);
690+
}
691+
}
564692
throw new Error(`TronGrid withdrawbalance failed: ${res.Error}`);
565693
}
566694
if (!res.txID || !res.raw_data_hex) {

src/modules/tron/status.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { TRONGRID_BASE_URL } from "../../config/tron.js";
2+
import { resolveTronApiKey, readUserConfig } from "../../config/user-config.js";
3+
4+
/**
5+
* TRON tx-status polling via TronGrid. Mirrors the EVM
6+
* get_transaction_status shape so the top-level MCP tool can route by chain
7+
* without the caller noticing the underlying API split.
8+
*
9+
* Endpoints used:
10+
* - /wallet/gettransactionbyid — returns the signed envelope once the tx
11+
* lands in the node's mempool / confirmed block. Empty object = unknown.
12+
* - /wallet/gettransactioninfobyid — returns block number, fee, and the
13+
* receipt (SUCCESS / REVERT / OUT_OF_ENERGY) once confirmed. Empty
14+
* object = not yet confirmed.
15+
*
16+
* A fresh broadcast typically shows up in gettransactionbyid within a
17+
* couple of seconds and in gettransactioninfobyid after the next block
18+
* (~3s). The "unknown" state only persists for truly-lost tx IDs or
19+
* extremely fresh broadcasts.
20+
*/
21+
22+
interface GetTxByIdResponse {
23+
txID?: string;
24+
raw_data?: {
25+
contract?: Array<{ type?: string; parameter?: unknown }>;
26+
};
27+
ret?: Array<{ contractRet?: string }>;
28+
}
29+
30+
interface GetTxInfoResponse {
31+
id?: string;
32+
blockNumber?: number;
33+
fee?: number;
34+
receipt?: {
35+
result?: string;
36+
energy_usage?: number;
37+
energy_usage_total?: number;
38+
net_usage?: number;
39+
};
40+
log?: unknown;
41+
contractResult?: string[];
42+
}
43+
44+
async function trongridPost<T>(path: string, body: Record<string, unknown>): Promise<T> {
45+
const apiKey = resolveTronApiKey(readUserConfig());
46+
const headers: Record<string, string> = { "Content-Type": "application/json" };
47+
if (apiKey) headers["TRON-PRO-API-KEY"] = apiKey;
48+
const res = await fetch(`${TRONGRID_BASE_URL}${path}`, {
49+
method: "POST",
50+
headers,
51+
body: JSON.stringify(body),
52+
});
53+
if (!res.ok) {
54+
throw new Error(`TronGrid ${path} returned ${res.status} ${res.statusText}`);
55+
}
56+
return (await res.json()) as T;
57+
}
58+
59+
export async function getTronTransactionStatus(txHash: string): Promise<{
60+
chain: "tron";
61+
txHash: string;
62+
status: "success" | "failed" | "pending" | "unknown";
63+
blockNumber?: string;
64+
feeTrx?: string;
65+
energyUsed?: string;
66+
receiptResult?: string;
67+
note?: string;
68+
}> {
69+
const normalized = txHash.replace(/^0x/, "").toLowerCase();
70+
71+
const [byId, info] = await Promise.all([
72+
trongridPost<GetTxByIdResponse>("/wallet/gettransactionbyid", { value: normalized }),
73+
trongridPost<GetTxInfoResponse>("/wallet/gettransactioninfobyid", { value: normalized }),
74+
]);
75+
76+
const seenInMempool = Boolean(byId?.txID);
77+
const confirmed = Boolean(info?.blockNumber);
78+
79+
if (!seenInMempool && !confirmed) {
80+
return {
81+
chain: "tron",
82+
txHash: normalized,
83+
status: "unknown",
84+
note: "Transaction not yet visible to TronGrid — it may still be propagating or the txID is wrong.",
85+
};
86+
}
87+
88+
if (!confirmed) {
89+
return { chain: "tron", txHash: normalized, status: "pending" };
90+
}
91+
92+
// Confirmed. Status comes from two places depending on tx type:
93+
// - smart-contract calls (TRC-20 transfer etc.): receipt.result is
94+
// "SUCCESS" / "REVERT" / "OUT_OF_ENERGY" / "OUT_OF_TIME" etc.
95+
// - native transfers (TransferContract, WithdrawBalance): no receipt,
96+
// check ret[0].contractRet from gettransactionbyid.
97+
const receiptResult = info.receipt?.result;
98+
const contractRet = byId.ret?.[0]?.contractRet;
99+
const successTag =
100+
receiptResult === "SUCCESS" || (!receiptResult && contractRet === "SUCCESS");
101+
const status: "success" | "failed" = successTag ? "success" : "failed";
102+
103+
const feeTrx =
104+
typeof info.fee === "number"
105+
? (info.fee / 1_000_000).toString()
106+
: undefined;
107+
108+
return {
109+
chain: "tron",
110+
txHash: normalized,
111+
status,
112+
blockNumber: info.blockNumber!.toString(),
113+
...(feeTrx !== undefined ? { feeTrx } : {}),
114+
...(info.receipt?.energy_usage_total !== undefined
115+
? { energyUsed: info.receipt.energy_usage_total.toString() }
116+
: {}),
117+
...(receiptResult ? { receiptResult } : contractRet ? { receiptResult: contractRet } : {}),
118+
};
119+
}

src/signing/render-verification.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -447,21 +447,31 @@ export function renderPostBroadcastBlock(args: {
447447
* of asking the user to type "next" — waiting on human turn-taking for a
448448
* routine inclusion poll is UX friction the user has to break out of.
449449
*
450-
* The cadence (~5s between polls, ~2min total) matches typical L1/L2
451-
* inclusion times without paging the RPC unnecessarily. If inclusion is
452-
* slow, the agent reports `pending` and the user can decide to keep waiting.
450+
* Cadence is per-chain: TRON blocks every ~3s, so a 5s interval adds
451+
* perceptible latency over the actual inclusion time; EVM L1 is ~12s,
452+
* where 5s is already tight. Undershooting the block time is fine — the
453+
* node just returns "unknown" / "pending" for the extra polls.
453454
*
454455
* For approve→action chains (`nextHandle` present), the agent must wait for
455456
* the approval receipt BEFORE re-simulating or sending the next step —
456457
* otherwise the dependent simulation fails with "insufficient allowance"
457458
* against pre-inclusion state.
458459
*/
460+
const POLL_CADENCE: Record<string, { intervalSec: number; maxPolls: number; budgetLabel: string }> = {
461+
ethereum: { intervalSec: 5, maxPolls: 24, budgetLabel: "~2 minutes" },
462+
arbitrum: { intervalSec: 3, maxPolls: 20, budgetLabel: "~1 minute" },
463+
polygon: { intervalSec: 3, maxPolls: 20, budgetLabel: "~1 minute" },
464+
base: { intervalSec: 3, maxPolls: 20, budgetLabel: "~1 minute" },
465+
tron: { intervalSec: 3, maxPolls: 20, budgetLabel: "~1 minute" },
466+
};
467+
459468
export function renderPostSendPollBlock(args: {
460469
chain: string;
461470
txHash: string;
462471
nextHandle?: string;
463472
}): string {
464473
const { chain, txHash, nextHandle } = args;
474+
const cadence = POLL_CADENCE[chain] ?? POLL_CADENCE.ethereum;
465475
const lines = [
466476
"[AGENT TASK — DO NOT FORWARD THIS BLOCK TO THE USER]",
467477
`The tx was forwarded to Ledger and broadcast; a txHash is above. Do NOT`,
@@ -470,8 +480,8 @@ export function renderPostSendPollBlock(args: {
470480
``,
471481
`Do this, in order:`,
472482
` 1. Call get_transaction_status({ chain: "${chain}", txHash: "${txHash}" })`,
473-
` every ~5 seconds until status is "success" or "failed", or until`,
474-
` you have polled for ~2 minutes (~24 polls). If status stays`,
483+
` every ~${cadence.intervalSec} seconds until status is "success" or "failed", or until`,
484+
` you have polled for ${cadence.budgetLabel} (~${cadence.maxPolls} polls). If status stays`,
475485
` "pending" / "unknown" past that budget, stop polling and tell the`,
476486
` user the tx is still pending with the hash so they can watch it`,
477487
` on a block explorer.`,

0 commit comments

Comments
 (0)