Skip to content

Commit 8515d7c

Browse files
committed
Fix Tron swap UX in the TRX/USDT flow
Read Tron balances from TronGrid's fullnode endpoints instead of the solidity node, and confirm Tron transactions with getUnconfirmedTransactionInfo, so balances and approvals no longer wait on solidification. Gate the same-chain completion shortcut on the step being atomic. Tron TRX/USDT, deposit-address routes and forced solver execution all report matching origin and destination chain ids while still requiring a solver fill, so keying on chain ids alone reported success before the fill landed and rendered the same-chain step sequence for them.
1 parent e89f53a commit 8515d7c

10 files changed

Lines changed: 245 additions & 24 deletions

File tree

.changeset/tron-swap-ux.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@relayprotocol/relay-sdk': patch
3+
'@relayprotocol/relay-kit-ui': patch
4+
'@relayprotocol/relay-tron-wallet-adapter': patch
5+
---
6+
7+
Fix Tron swap UX in the TRX/USDT flow. Tron balances now read from TronGrid's
8+
fullnode endpoints (`wallet/getaccount`, `wallet/triggerconstantcontract`)
9+
instead of the solidity node, so they reflect a completed swap without waiting
10+
roughly a minute for solidification or needing a manual refresh.
11+
`adaptTronWallet` confirms transactions with `getUnconfirmedTransactionInfo`,
12+
which returns the receipt seconds after inclusion, so a successful approval no
13+
longer hangs or reports a false "Transaction confirmation timed out".
14+
15+
Same-chain swaps that the solver has to fill — Tron TRX/USDT, deposit-address
16+
routes, and forced solver execution — now show the cross-chain pending states
17+
and wait for the fill to be confirmed before reporting success. Previously any
18+
route whose origin and destination chain ids matched was assumed to settle with
19+
the user's own transaction. The new `isSolverFilledStep` export identifies these
20+
routes from the step the API returns.

packages/relay-tron-wallet-adapter/src/adapter.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ export const adaptTronWallet = (
7272
},
7373
handleConfirmTransactionStep: async (txId) => {
7474
const pollMs = 1500
75-
const timeoutMs = 60_000
75+
const timeoutMs = 90_000
7676
const targetConfs = 1
7777

7878
const start = Date.now()
@@ -81,9 +81,10 @@ export const adaptTronWallet = (
8181
0
8282

8383
while (true) {
84-
// 1) Ask for the execution receipt (appears once included in a block)
84+
// 1) Ask for the execution receipt (appears once included in a block).
85+
// Fullnode variant: the solidity node only serves it after solidification (~57s).
8586
const info = await tronWeb.trx
86-
.getTransactionInfo(txId)
87+
.getUnconfirmedTransactionInfo(txId)
8788
.catch(() => undefined)
8889

8990
if (info && typeof info.blockNumber === 'number') {

packages/sdk/src/utils/executeSteps/executeSteps.test.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { postSignatureExtraSteps } from '../../../tests/data/postSignatureExtraS
2020
import { swapWithApproval } from '../../../tests/data/swapWithApproval'
2121
import { swapWithZeroResetApproval } from '../../../tests/data/swapWithZeroResetApproval'
2222
import { adaptViemWallet } from '../viemWallet'
23+
import { sameChainDeposit } from '../../../tests/data/sameChainDeposit'
2324

2425
const viemChains = [mainnet, base, zora, optimism, arbitrum, arbitrumNova]
2526
const relayChains = viemChains.map(convertViemChainToRelayChain)
@@ -1756,3 +1757,97 @@ describe('Should test WebSocket functionality', () => {
17561757
})
17571758
})
17581759
})
1760+
1761+
describe('Same-chain step completion.', () => {
1762+
let statusSpy: ReturnType<typeof vi.spyOn>
1763+
1764+
const mockStatusSequence = (statuses: string[]) => {
1765+
let index = 0
1766+
statusSpy = vi.spyOn(axios, 'request').mockImplementation((config: any) => {
1767+
if (config.url?.includes('/intents/status')) {
1768+
const status = statuses[Math.min(index, statuses.length - 1)]
1769+
index++
1770+
return Promise.resolve({
1771+
data: { status, txHashes: ['0x'] },
1772+
status: 200
1773+
}) as any
1774+
}
1775+
return Promise.resolve({
1776+
data: { status: 'success' },
1777+
status: 200
1778+
}) as any
1779+
})
1780+
return statusSpy
1781+
}
1782+
1783+
const statusCallCount = () =>
1784+
statusSpy.mock.calls.filter((call: any) =>
1785+
call[0]?.url?.includes('/intents/status')
1786+
).length
1787+
1788+
beforeEach(() => {
1789+
vi.clearAllMocks()
1790+
vi.resetAllMocks()
1791+
axiosPostSpy = mockAxiosPost()
1792+
wallet = {
1793+
vmType: 'evm',
1794+
getChainId: () => Promise.resolve(1),
1795+
transport: http(mainnet.rpcUrls.default.http[0]),
1796+
address: () => Promise.resolve('0x'),
1797+
handleSignMessageStep: vi.fn().mockResolvedValue('0x'),
1798+
handleSendTransactionStep: vi.fn().mockResolvedValue('0x'),
1799+
handleConfirmTransactionStep: vi.fn().mockResolvedValue('0x'),
1800+
switchChain: vi.fn().mockResolvedValue('0x'),
1801+
supportsAtomicBatch: vi.fn().mockResolvedValue(false),
1802+
handleBatchTransactionStep: vi.fn().mockResolvedValue('0x')
1803+
}
1804+
client = createClient({
1805+
baseApiUrl: MAINNET_RELAY_API,
1806+
chains: relayChains,
1807+
pollingInterval: 10
1808+
})
1809+
})
1810+
1811+
it('Should complete an atomic same-chain swap without waiting for a solver fill.', async () => {
1812+
mockStatusSequence(['pending', 'pending', 'success'])
1813+
1814+
await executeSteps(
1815+
1,
1816+
{},
1817+
wallet,
1818+
() => {},
1819+
JSON.parse(JSON.stringify(swapWithApproval)) as Execute,
1820+
undefined
1821+
)
1822+
1823+
// The origin receipt settles the swap, so the fill status is never awaited.
1824+
expect(statusCallCount()).toBeLessThanOrEqual(1)
1825+
})
1826+
1827+
it('Should wait for the solver fill on a same-chain deposit step.', async () => {
1828+
mockStatusSequence(['pending', 'pending', 'success'])
1829+
1830+
await executeSteps(
1831+
1,
1832+
{},
1833+
wallet,
1834+
() => {},
1835+
JSON.parse(JSON.stringify(sameChainDeposit)) as Execute,
1836+
undefined
1837+
)
1838+
1839+
expect(statusCallCount()).toBeGreaterThanOrEqual(3)
1840+
})
1841+
1842+
it('Should complete an atomic same-chain send without waiting for a solver fill.', async () => {
1843+
mockStatusSequence(['pending', 'pending', 'success'])
1844+
1845+
const quote = JSON.parse(JSON.stringify(sameChainDeposit)) as Execute
1846+
quote.steps[0].id = 'send'
1847+
1848+
await executeSteps(1, {}, wallet, () => {}, quote, undefined)
1849+
1850+
expect(statusCallCount()).toBeLessThanOrEqual(1)
1851+
expect(quote.steps[0].items?.[0].status).toBe('complete')
1852+
})
1853+
})

packages/sdk/src/utils/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export {
1212
} from './viemWallet.js'
1313
export { convertViemChainToRelayChain, type RelayAPIChain } from './chain.js'
1414
export { getCurrentStepData } from './getCurrentStepData.js'
15+
export { isSolverFilledStep } from './solverFill.js'
1516
export {
1617
type SimulateContractRequest,
1718
isSimulateContractRequest
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { describe, it, expect } from 'vitest'
2+
import { isSolverFilledStep } from './solverFill'
3+
import type { Execute } from '../types'
4+
5+
const step = (id: string) => ({ id }) as Execute['steps'][0]
6+
7+
describe('isSolverFilledStep', () => {
8+
it('Should treat deposit steps as solver filled.', () => {
9+
expect(isSolverFilledStep(step('deposit'))).toBe(true)
10+
})
11+
12+
it('Should treat every other transaction step as atomic.', () => {
13+
for (const id of ['swap', 'send', 'approve', 'approval']) {
14+
expect(isSolverFilledStep(step(id))).toBe(false)
15+
}
16+
})
17+
})
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import type { Execute } from '../types/index.js'
2+
3+
/**
4+
* True when the step is filled by the solver rather than settled atomically by
5+
* the user's own transaction.
6+
*
7+
* The API emits `deposit` whenever the request is routed as an intent — every
8+
* cross-chain route, deposit-address routes, forced solver execution, and
9+
* same-chain routes with no on-chain aggregator to swap through (Tron). Origin
10+
* and destination chain ids are not a reliable signal: those same-chain intents
11+
* report matching ids while still requiring a separate fill.
12+
*
13+
* Atomic steps (`swap`, `send`) complete with the origin transaction and must
14+
* not be made to wait on the status API.
15+
*/
16+
export const isSolverFilledStep = (step: Execute['steps'][0]): boolean =>
17+
step.id === 'deposit'

packages/sdk/src/utils/transaction.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import type {
1717
AxiosResponse
1818
} from 'axios'
1919
import { getClient } from '../client.js'
20+
import { isSolverFilledStep } from './solverFill.js'
2021
import {
2122
DepositTransactionTimeoutError,
2223
SolverStatusTimeoutError,
@@ -524,10 +525,14 @@ export async function sendTransactionSafely(
524525
const confirmationPromise = pollForConfirmation(receiptController)
525526

526527
await Promise.race([receiptPromise, confirmationPromise])
527-
const isSameChain = details?.currencyOut?.currency?.chainId === chainId
528+
// Same-chain intents (deposit) still need a solver fill, so only atomic
529+
// same-chain steps are complete once the origin receipt lands.
530+
const isSameChainAtomic =
531+
details?.currencyOut?.currency?.chainId === chainId &&
532+
!isSolverFilledStep(step)
528533

529534
if (waitingForConfirmation) {
530-
if (!isSameChain) {
535+
if (!isSameChainAtomic) {
531536
await confirmationPromise
532537
} else {
533538
waitingForConfirmation = false
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import type { Execute } from '../../src/types'
2+
3+
/**
4+
* Same-chain swap routed as an intent: matching chain ids, but a `deposit` step
5+
* the solver has to fill. Produced by deposit-address routes, forced solver
6+
* execution, and chains with no on-chain aggregator (Tron).
7+
*/
8+
export const sameChainDeposit: Execute = {
9+
steps: [
10+
{
11+
id: 'deposit',
12+
action: 'Confirm transaction in your wallet',
13+
description: 'Depositing funds to the relayer to execute the swap',
14+
kind: 'transaction',
15+
requestId: '0xabc',
16+
items: [
17+
{
18+
status: 'incomplete',
19+
data: {
20+
to: '0x00000000bb6dd3b0032d930f72cac8e56166d93c',
21+
data: '0x01020304',
22+
value: '1000000000000000',
23+
chainId: 1
24+
},
25+
check: {
26+
endpoint: '/intents/status?requestId=0xabc',
27+
method: 'GET'
28+
}
29+
}
30+
]
31+
}
32+
],
33+
fees: {},
34+
details: {
35+
operation: 'swap',
36+
sender: '0x03508bB71268BBA25ECaCC8F620e01866650532c',
37+
recipient: '0x03508bB71268BBA25ECaCC8F620e01866650532c',
38+
currencyIn: {
39+
currency: {
40+
chainId: 1,
41+
address: '0x0000000000000000000000000000000000000000',
42+
symbol: 'ETH',
43+
name: 'Ether',
44+
decimals: 18
45+
},
46+
amount: '1000000000000000',
47+
amountFormatted: '0.001',
48+
amountUsd: '3.417570'
49+
},
50+
currencyOut: {
51+
currency: {
52+
chainId: 1,
53+
address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48',
54+
symbol: 'USDC',
55+
name: 'USDCoin',
56+
decimals: 6
57+
},
58+
amount: '3410000',
59+
amountFormatted: '3.41',
60+
amountUsd: '3.410000'
61+
}
62+
}
63+
}

packages/ui/src/hooks/useTronBalance.ts

Lines changed: 12 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ export default (
147147
queryFn: async () => {
148148
if (address) {
149149
if (currency === trxAddress || !currency) {
150-
const response = await fetch(`${rpcUrl}/walletsolidity/getaccount`, {
150+
const response = await fetch(`${rpcUrl}/wallet/getaccount`, {
151151
method: 'POST',
152152
headers: {
153153
'Content-Type': 'application/json'
@@ -173,20 +173,17 @@ export default (
173173
const owner20 = await tronBase58ToHex20Async(address)
174174
const parameter = pad32(owner20.slice(2))
175175

176-
const res = await fetch(
177-
`${rpcUrl}/walletsolidity/triggerconstantcontract`,
178-
{
179-
method: 'POST',
180-
headers: { 'content-type': 'application/json' },
181-
body: JSON.stringify({
182-
owner_address: address,
183-
contract_address: currency,
184-
function_selector: 'balanceOf(address)',
185-
parameter,
186-
visible: true
187-
})
188-
}
189-
)
176+
const res = await fetch(`${rpcUrl}/wallet/triggerconstantcontract`, {
177+
method: 'POST',
178+
headers: { 'content-type': 'application/json' },
179+
body: JSON.stringify({
180+
owner_address: address,
181+
contract_address: currency,
182+
function_selector: 'balanceOf(address)',
183+
parameter,
184+
visible: true
185+
})
186+
})
190187
const data = await res.json()
191188

192189
if (data.error) {

packages/ui/src/utils/steps.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { Execute, RelayChain } from '@relayprotocol/relay-sdk'
2+
import { isSolverFilledStep } from '@relayprotocol/relay-sdk'
23
import type { Token, LinkedWallet } from '../types/index.js'
34
import { NormalizedWalletName } from '../constants/walletCompatibility.js'
45

@@ -187,8 +188,10 @@ export const formatTransactionSteps = ({
187188
(step) => step.id === 'approve' || (step.id as any) === 'approval'
188189
)
189190

190-
// Determine transaction type
191-
const isSameChain = fromChain?.id === toChain?.id
191+
// Determine transaction type. Same-chain intents (deposit) are filled by the
192+
// solver, so they take the cross-chain sequence despite matching chain ids.
193+
const isSameChainId = fromChain?.id === toChain?.id
194+
const usesSolverFill = executableSteps.some(isSolverFilledStep)
192195

193196
// Find current active step and its state
194197
const currentActiveStep = executableSteps.find((step) =>
@@ -211,8 +214,10 @@ export const formatTransactionSteps = ({
211214
toChain?.id ||
212215
quote?.details?.currencyOut?.currency?.chainId
213216

217+
// Keyed on chain ids, not the step sequence: when they match, an origin hash
218+
// is indistinguishable from a destination one.
214219
const hasDestinationTxHashes =
215-
!isSameChain &&
220+
!isSameChainId &&
216221
!!destinationChainId &&
217222
executableSteps.some((step) =>
218223
step.items?.some(
@@ -445,7 +450,7 @@ export const formatTransactionSteps = ({
445450
}
446451

447452
// Create fixed step sequence based on transaction type
448-
if (isSameChain) {
453+
if (isSameChainId && !usesSolverFill) {
449454
// Same-chain: 1-2 steps (approval + swap, or just swap)
450455
if (hasApproval) {
451456
// Step 1: Approval

0 commit comments

Comments
 (0)