@@ -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 ( / ^ 0 x / , "" ) ;
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
99205export 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 ( / 2 4 h o u r s s i n c e l a s t W i t h d r a w / 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 ) {
0 commit comments