Skip to content

Commit e0b83de

Browse files
authored
Merge branch 'main' into ted/standardize-commit-messages
2 parents 0cce196 + 581e9d8 commit e0b83de

11 files changed

Lines changed: 223 additions & 17 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@relayprotocol/relay-sdk': patch
3+
'@relayprotocol/relay-kit-ui': patch
4+
---
5+
6+
Fix swap showing an error after already succeeding. When the websocket confirmed a request as successful, step execution stayed blocked on the RPC receipt lookup; if that RPC call then failed, executeSteps rejected with a TransactionConfirmationError and the swap widget flipped from Success to Error. Websocket success now resolves the step directly, late receipt errors are ignored once the backend has confirmed success, and the transaction modal no longer downgrades a Success state to Error.

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

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1485,6 +1485,105 @@ describe('Should test WebSocket functionality', () => {
14851485
expect(mockWebSocket.close).toHaveBeenCalled()
14861486
})
14871487

1488+
it('Should resolve execution on WebSocket success without waiting for the RPC receipt', async () => {
1489+
// handleConfirmTransactionStep never resolves (see beforeEach), so the only
1490+
// way execution can finish is via the websocket success message
1491+
const execution = executeSteps(
1492+
1,
1493+
{},
1494+
wallet,
1495+
() => {},
1496+
bridgeData,
1497+
undefined
1498+
)
1499+
1500+
await vi.waitFor(() => {
1501+
expect(wsConstructorSpy).toHaveBeenCalled()
1502+
})
1503+
mockWebSocket.onopen?.()
1504+
1505+
mockWebSocket.onmessage?.({
1506+
data: JSON.stringify({
1507+
event: 'request.status.updated',
1508+
data: {
1509+
status: 'success',
1510+
txHashes: ['0x123'],
1511+
inTxHashes: ['0xabc'],
1512+
destinationChainId: 8453,
1513+
originChainId: 1
1514+
}
1515+
})
1516+
})
1517+
1518+
const result = await execution
1519+
expect(result.error).toBeUndefined()
1520+
expect(
1521+
result.steps.every((step) =>
1522+
step.items?.every((item) => item.status === 'complete')
1523+
)
1524+
).toBe(true)
1525+
})
1526+
1527+
it('Should not fail when the RPC receipt errors after WebSocket success', async () => {
1528+
// Make the receipt lookup controllable so we can fail it after the backend
1529+
// has already confirmed success over the websocket
1530+
let rejectReceipt: ((error: Error) => void) | undefined
1531+
wallet.handleConfirmTransactionStep = vi.fn().mockImplementation(
1532+
() =>
1533+
new Promise((_, reject) => {
1534+
rejectReceipt = reject
1535+
})
1536+
)
1537+
1538+
const progressSpy = vi.fn()
1539+
const execution = executeSteps(
1540+
1,
1541+
{},
1542+
wallet,
1543+
(data) => progressSpy(data),
1544+
bridgeData,
1545+
undefined
1546+
)
1547+
1548+
await vi.waitFor(() => {
1549+
expect(wsConstructorSpy).toHaveBeenCalled()
1550+
expect(rejectReceipt).toBeDefined()
1551+
})
1552+
mockWebSocket.onopen?.()
1553+
1554+
mockWebSocket.onmessage?.({
1555+
data: JSON.stringify({
1556+
event: 'request.status.updated',
1557+
data: {
1558+
status: 'success',
1559+
txHashes: ['0x123'],
1560+
inTxHashes: ['0xabc'],
1561+
destinationChainId: 8453,
1562+
originChainId: 1
1563+
}
1564+
})
1565+
})
1566+
1567+
// Now the RPC "fails" (e.g. node error / receipt not found) after success
1568+
rejectReceipt?.(new Error('HTTP request failed: 502 Bad Gateway'))
1569+
1570+
const result = await execution
1571+
expect(result.error).toBeUndefined()
1572+
expect(result.steps[0].items?.[0].status).toBe('complete')
1573+
expect(result.steps[0].items?.[0].error).toBeUndefined()
1574+
1575+
// No progress update should ever have carried an error
1576+
const erroredUpdates = progressSpy.mock.calls.filter(
1577+
([data]) =>
1578+
data?.error ||
1579+
data?.steps?.some(
1580+
(step: Execute['steps'][0]) =>
1581+
step.error || step.items?.some((item) => item.error)
1582+
)
1583+
)
1584+
expect(erroredUpdates).toHaveLength(0)
1585+
})
1586+
14881587
it('Should fall back to polling on WebSocket error', async () => {
14891588
const stateUpdates: any[] = []
14901589

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

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,11 @@ export async function executeSteps(
101101
let terminalStatusPromise: Promise<never> | null = null
102102
let rejectTerminalStatus: ((error: Error) => void) | null = null
103103

104+
// Resolves when the websocket reports a terminal success, so step execution
105+
// doesn't stay blocked on (or fail because of) the RPC receipt lookup
106+
let successStatusPromise: Promise<void> | null = null
107+
let resolveSuccessStatus: (() => void) | null = null
108+
104109
// Promise-based approach for WebSocket failure handling
105110
let websocketFailedPromise: Promise<void> | null = null
106111
let resolveWebsocketFailed: (() => void) | null = null
@@ -228,6 +233,10 @@ export async function executeSteps(
228233
rejectTerminalStatus = reject
229234
})
230235

236+
successStatusPromise = new Promise<void>((resolve) => {
237+
resolveSuccessStatus = resolve
238+
})
239+
231240
statusControl.closeWebSocket = trackRequestStatus({
232241
event: 'request.status.updated',
233242
requestId: requestId,
@@ -249,6 +258,10 @@ export async function executeSteps(
249258
onTerminalError: (error: Error) => {
250259
// Immediately reject when terminal status received
251260
rejectTerminalStatus?.(error)
261+
},
262+
onTerminalSuccess: () => {
263+
// Immediately resolve when the backend confirms success
264+
resolveSuccessStatus?.()
252265
}
253266
})
254267
},
@@ -360,9 +373,14 @@ export async function executeSteps(
360373
}
361374
})()
362375

363-
// Allow WebSocket terminal status (failure/refund) to immediately interrupt and stop step execution
376+
// Allow WebSocket terminal status to immediately settle step execution:
377+
// failure/refund rejects, success resolves without waiting on the RPC receipt
364378
if (statusControl.websocketActive && terminalStatusPromise) {
365-
await Promise.race([stepExecutionPromise, terminalStatusPromise])
379+
await Promise.race([
380+
stepExecutionPromise,
381+
terminalStatusPromise,
382+
...(successStatusPromise ? [successStatusPromise] : [])
383+
])
366384
} else {
367385
await stepExecutionPromise
368386
}
@@ -379,6 +397,32 @@ export async function executeSteps(
379397
})
380398
resolve(stepItem)
381399
} catch (e) {
400+
// If the backend already marked this item complete (e.g. via websocket),
401+
// a late error from the execution promise must not flip it to a failure
402+
if (
403+
stepItem.status === 'complete' ||
404+
statusControl.lastKnownStatus === 'success'
405+
) {
406+
client.log(
407+
[
408+
'Execute Steps: Ignoring error for step item already confirmed complete',
409+
e
410+
],
411+
LogLevel.Warn
412+
)
413+
stepItem.status = 'complete'
414+
stepItem.progressState = 'complete'
415+
stepItem.isValidatingSignature = false
416+
setState({
417+
steps: [...json?.steps],
418+
fees: { ...json?.fees },
419+
breakdown: json?.breakdown,
420+
details: json?.details
421+
})
422+
resolve(stepItem)
423+
return
424+
}
425+
382426
const error = e as Error
383427
const errorMessage = error
384428
? error.message

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ interface WebSocketUpdateHandlerParams {
1717
websocketFailureTimeoutId?: ReturnType<typeof setTimeout> | null
1818
}
1919
onTerminalError?: (error: Error) => void
20+
onTerminalSuccess?: () => void
2021
}
2122

2223
export function handleWebSocketUpdate({
@@ -27,7 +28,8 @@ export function handleWebSocketUpdate({
2728
json,
2829
client,
2930
statusControl,
30-
onTerminalError
31+
onTerminalError,
32+
onTerminalSuccess
3133
}: WebSocketUpdateHandlerParams): void {
3234
statusControl.lastKnownStatus = data.status
3335

@@ -52,6 +54,7 @@ export function handleWebSocketUpdate({
5254
switch (data.status) {
5355
case 'success':
5456
handleSuccessStatus(data, stepItems, chainId, setState, json, client)
57+
onTerminalSuccess?.()
5558
break
5659
case 'refund':
5760
handleRefundStatus(client, stepItems, onTerminalError)

packages/sdk/src/utils/transaction.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,21 @@ export async function sendTransactionSafely(
452452
return
453453
}
454454

455+
// The backend (via websocket) has already confirmed the request succeeded.
456+
// A late RPC receipt error at this point is a node/RPC issue, not a failed
457+
// transaction, so don't surface it as a TransactionConfirmationError.
458+
if (statusControl?.lastKnownStatus === 'success') {
459+
waitingForConfirmation = false
460+
getClient()?.log(
461+
[
462+
'Ignoring receipt error, request already confirmed successful by backend',
463+
error
464+
],
465+
LogLevel.Warn
466+
)
467+
return
468+
}
469+
455470
let tenderlyError: TenderlyErrorInfo | null = null
456471

457472
if (receipt && (receipt as TransactionReceipt).transactionHash) {

packages/ui/CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
11
# @reservoir0x/relay-kit-ui
22

3+
## 11.0.5
4+
5+
### Patch Changes
6+
7+
- e6ca48c: Align swap widget fee display with the Dashboard and transaction page:
8+
"Swap Impact" is now "Swap Cost", "Relay Fee" is "Platform Fee" (with a green
9+
"(Reward)" note when Relay pays the user), "Execution Fee" is "Execution
10+
Cost", and "Network cost" is "Deposit gas". Credits render green with a
11+
leading "+", zero fees display as "$0.00" instead of "-", and sub-cent
12+
values as signed "+< $0.01" / "-< $0.01".
13+
314
## 11.0.4
415

516
### Patch Changes

packages/ui/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@relayprotocol/relay-kit-ui",
3-
"version": "11.0.4",
3+
"version": "11.0.5",
44
"repository": {
55
"type": "git",
66
"url": "git+https://github.com/relayprotocol/relay-kit.git",

packages/ui/src/components/common/TransactionModal/TransactionModalRenderer.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,11 @@ export const TransactionModalRenderer: FC<Props> = ({
118118

119119
useEffect(() => {
120120
if (swapError) {
121-
setProgressStep(TransactionProgressStep.Error)
121+
// Once the swap has been confirmed successful, a late error (e.g. an RPC
122+
// failure while fetching the receipt) must not flip the modal to an error state
123+
if (progressStep !== TransactionProgressStep.Success) {
124+
setProgressStep(TransactionProgressStep.Error)
125+
}
122126
return
123127
}
124128
if (!steps) {

packages/ui/src/components/widgets/FeeBreakdown.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ const FeeBreakdown: FC<Props> = ({
118118
)
119119
},
120120
{
121-
title: 'Network cost',
121+
title: 'Deposit gas',
122122
value: (
123123
<Flex align="center" className="relay:gap-1">
124124
<FontAwesomeIcon
@@ -174,7 +174,10 @@ const FeeBreakdown: FC<Props> = ({
174174
className="relay:rounded-[var(--relay-radii-widget-card-border-radius)] relay:bg-[var(--relay-colors-widget-background)] relay:border-widget-card relay:overflow-hidden relay:mb-[var(--relay-spacing-widget-card-section-gutter)]"
175175
>
176176
<div className="relay:mt-0 relay:mb-0 relay:px-3 relay:py-[12px] relay:w-full relay:flex relay:justify-center">
177-
<FetchingQuoteLoader isLoading={isFetchingQuote} containerClassName="relay:!my-0 relay:!py-0" />
177+
<FetchingQuoteLoader
178+
isLoading={isFetchingQuote}
179+
containerClassName="relay:!my-0 relay:!py-0"
180+
/>
178181
</div>
179182
</Box>
180183
)

packages/ui/src/components/widgets/PriceImpactTooltip.tsx

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ export const PriceImpactTooltip: FC<PriceImpactTooltipProps> = ({
4646
/>
4747
<Flex align="center" className="relay:w-full">
4848
<Text style="subtitle3" color="subtle" className="relay:mr-auto">
49-
Swap Impact
49+
Swap Cost
5050
</Text>
5151
<Text
5252
style="subtitle3"
@@ -59,11 +59,20 @@ export const PriceImpactTooltip: FC<PriceImpactTooltipProps> = ({
5959
if (fee.id === 'origin-gas') {
6060
return null
6161
}
62+
// Positive platform fee = rebalancing reward paid to the user.
63+
const isReward = fee.id === 'relayer-fee' && fee.usd.value > 0
6264
return (
6365
<Flex key={fee.id} align="center" className="relay:w-full">
64-
<Text style="subtitle3" color="subtle" className="relay:mr-auto">
65-
{fee.name}
66-
</Text>
66+
<Flex align="center" className="relay:mr-auto relay:gap-1">
67+
<Text style="subtitle3" color="subtle">
68+
{fee.name}
69+
</Text>
70+
{isReward && (
71+
<Text style="subtitle3" color="success">
72+
(Reward)
73+
</Text>
74+
)}
75+
</Flex>
6776
{feeBreakdown.isGasSponsored && fee.usd.value === 0 ? (
6877
<Text style="subtitle3" color="success">
6978
Free

0 commit comments

Comments
 (0)