@portal-hq/web 3.13.1 → 3.13.2-0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/lib/commonjs/index.js +127 -9
  2. package/lib/commonjs/index.test.js +13 -0
  3. package/lib/commonjs/integrations/delegations/index.js +109 -2
  4. package/lib/commonjs/integrations/delegations/index.test.js +171 -0
  5. package/lib/commonjs/integrations/ramps/noah/index.test.js +18 -5
  6. package/lib/commonjs/integrations/trading/index.js +16 -5
  7. package/lib/commonjs/integrations/trading/lifi/index.js +297 -25
  8. package/lib/commonjs/integrations/trading/lifi/lifi.tradeAsset.test.js +360 -0
  9. package/lib/commonjs/integrations/trading/lifi/lifiStatusPoll.js +118 -0
  10. package/lib/commonjs/integrations/trading/lifi/lifiStatusPoll.test.js +66 -0
  11. package/lib/commonjs/integrations/trading/zero-x/index.js +129 -26
  12. package/lib/commonjs/integrations/trading/zero-x/index.test.js +163 -1
  13. package/lib/commonjs/integrations/yield/index.js +18 -4
  14. package/lib/commonjs/integrations/yield/yieldxyz.getValidators.test.js +71 -0
  15. package/lib/commonjs/integrations/yield/yieldxyz.highLevel.test.js +438 -0
  16. package/lib/commonjs/integrations/yield/yieldxyz.js +541 -1
  17. package/lib/commonjs/internal/pollLoop.js +64 -0
  18. package/lib/commonjs/internal/pollLoop.test.js +100 -0
  19. package/lib/commonjs/internal/stripStalePlanningNonce.js +65 -0
  20. package/lib/commonjs/internal/stripStalePlanningNonce.test.js +35 -0
  21. package/lib/commonjs/internal/waitForEvmOrUserOpConfirmation.js +155 -0
  22. package/lib/commonjs/internal/waitForEvmOrUserOpConfirmation.test.js +33 -0
  23. package/lib/commonjs/internal/waitForEvmTxConfirmation.js +104 -0
  24. package/lib/commonjs/internal/waitForSolanaTxConfirmation.js +106 -0
  25. package/lib/commonjs/internal/yieldEvmNetwork.js +60 -0
  26. package/lib/commonjs/mpc/index.js +116 -1
  27. package/lib/commonjs/provider/index.js +17 -0
  28. package/lib/commonjs/shared/trace/index.js +0 -1
  29. package/lib/esm/index.js +127 -9
  30. package/lib/esm/index.test.js +13 -0
  31. package/lib/esm/integrations/delegations/index.js +109 -2
  32. package/lib/esm/integrations/delegations/index.test.js +171 -0
  33. package/lib/esm/integrations/ramps/noah/index.test.js +18 -5
  34. package/lib/esm/integrations/trading/index.js +16 -5
  35. package/lib/esm/integrations/trading/lifi/index.js +292 -25
  36. package/lib/esm/integrations/trading/lifi/lifi.tradeAsset.test.js +332 -0
  37. package/lib/esm/integrations/trading/lifi/lifiStatusPoll.js +113 -0
  38. package/lib/esm/integrations/trading/lifi/lifiStatusPoll.test.js +64 -0
  39. package/lib/esm/integrations/trading/zero-x/index.js +129 -26
  40. package/lib/esm/integrations/trading/zero-x/index.test.js +141 -2
  41. package/lib/esm/integrations/yield/index.js +18 -4
  42. package/lib/esm/integrations/yield/yieldxyz.getValidators.test.js +66 -0
  43. package/lib/esm/integrations/yield/yieldxyz.highLevel.test.js +433 -0
  44. package/lib/esm/integrations/yield/yieldxyz.js +541 -1
  45. package/lib/esm/internal/pollLoop.js +59 -0
  46. package/lib/esm/internal/pollLoop.test.js +98 -0
  47. package/lib/esm/internal/stripStalePlanningNonce.js +61 -0
  48. package/lib/esm/internal/stripStalePlanningNonce.test.js +33 -0
  49. package/lib/esm/internal/waitForEvmOrUserOpConfirmation.js +151 -0
  50. package/lib/esm/internal/waitForEvmOrUserOpConfirmation.test.js +31 -0
  51. package/lib/esm/internal/waitForEvmTxConfirmation.js +100 -0
  52. package/lib/esm/internal/waitForSolanaTxConfirmation.js +102 -0
  53. package/lib/esm/internal/yieldEvmNetwork.js +55 -0
  54. package/lib/esm/mpc/index.js +116 -1
  55. package/lib/esm/provider/index.js +17 -0
  56. package/lib/esm/shared/trace/index.js +0 -1
  57. package/noah-types.d.ts +16 -2
  58. package/package.json +3 -2
  59. package/src/index.test.ts +15 -0
  60. package/src/index.ts +203 -14
  61. package/src/integrations/delegations/index.test.ts +251 -0
  62. package/src/integrations/delegations/index.ts +202 -4
  63. package/src/integrations/ramps/noah/index.test.ts +18 -5
  64. package/src/integrations/trading/index.ts +10 -7
  65. package/src/integrations/trading/lifi/index.ts +388 -28
  66. package/src/integrations/trading/lifi/lifi.tradeAsset.test.ts +436 -0
  67. package/src/integrations/trading/lifi/lifiStatusPoll.test.ts +74 -0
  68. package/src/integrations/trading/lifi/lifiStatusPoll.ts +158 -0
  69. package/src/integrations/trading/zero-x/index.test.ts +297 -1
  70. package/src/integrations/trading/zero-x/index.ts +181 -27
  71. package/src/integrations/yield/index.ts +24 -4
  72. package/src/integrations/yield/yieldxyz.getValidators.test.ts +70 -0
  73. package/src/integrations/yield/yieldxyz.highLevel.test.ts +536 -0
  74. package/src/integrations/yield/yieldxyz.ts +762 -8
  75. package/src/internal/pollLoop.test.ts +109 -0
  76. package/src/internal/pollLoop.ts +87 -0
  77. package/src/internal/stripStalePlanningNonce.test.ts +38 -0
  78. package/src/internal/stripStalePlanningNonce.ts +66 -0
  79. package/src/internal/waitForEvmOrUserOpConfirmation.test.ts +31 -0
  80. package/src/internal/waitForEvmOrUserOpConfirmation.ts +194 -0
  81. package/src/internal/waitForEvmTxConfirmation.ts +155 -0
  82. package/src/internal/waitForSolanaTxConfirmation.ts +135 -0
  83. package/src/internal/yieldEvmNetwork.ts +57 -0
  84. package/src/mpc/index.ts +142 -1
  85. package/src/provider/index.ts +25 -0
  86. package/src/shared/trace/index.ts +0 -1
  87. package/src/shared/types/README.md +6 -0
  88. package/src/shared/types/api.ts +12 -1
  89. package/src/shared/types/common.ts +332 -20
  90. package/src/shared/types/delegations.ts +10 -0
  91. package/src/shared/types/index.ts +1 -0
  92. package/src/shared/types/lifi.ts +82 -0
  93. package/src/shared/types/noah.ts +124 -33
  94. package/src/shared/types/yieldxyz.ts +193 -0
  95. package/src/shared/types/zero-x.ts +66 -0
  96. package/types.d.ts +6 -0
@@ -0,0 +1,109 @@
1
+ import { pollLoop, PollLoopTimeoutError, type PollTickResult } from './pollLoop'
2
+
3
+ describe('pollLoop', () => {
4
+ it('returns immediately when tick returns stop', async () => {
5
+ const tick = jest.fn().mockResolvedValue({
6
+ kind: 'stop',
7
+ value: 42,
8
+ } as PollTickResult<number>)
9
+
10
+ await expect(
11
+ pollLoop({
12
+ tick,
13
+ intervalMs: 1000,
14
+ timeoutMs: 5000,
15
+ }),
16
+ ).resolves.toBe(42)
17
+ expect(tick).toHaveBeenCalledTimes(1)
18
+ })
19
+
20
+ it('rejects when tick returns throw', async () => {
21
+ const err = new Error('tick failed')
22
+ const tick = jest.fn().mockResolvedValue({
23
+ kind: 'throw',
24
+ error: err,
25
+ } as PollTickResult<never>)
26
+
27
+ await expect(
28
+ pollLoop({
29
+ tick,
30
+ intervalMs: 100,
31
+ timeoutMs: 1000,
32
+ }),
33
+ ).rejects.toThrow('tick failed')
34
+ })
35
+
36
+ it('throws PollLoopTimeoutError when tick always continues', async () => {
37
+ jest.useFakeTimers()
38
+ const tick = jest.fn().mockResolvedValue({ kind: 'continue' } as const)
39
+
40
+ const p = pollLoop({
41
+ tick,
42
+ intervalMs: 10,
43
+ timeoutMs: 100,
44
+ })
45
+
46
+ const expectTimeout = expect(p).rejects.toBeInstanceOf(PollLoopTimeoutError)
47
+ await jest.runAllTimersAsync()
48
+ await expectTimeout
49
+ jest.useRealTimers()
50
+ })
51
+
52
+ it('waits initialDelayMs before first tick', async () => {
53
+ jest.useFakeTimers()
54
+ const tick = jest.fn().mockResolvedValue({
55
+ kind: 'stop',
56
+ value: true,
57
+ } as PollTickResult<boolean>)
58
+
59
+ const p = pollLoop({
60
+ tick,
61
+ intervalMs: 100,
62
+ initialDelayMs: 300,
63
+ timeoutMs: 2000,
64
+ })
65
+
66
+ expect(tick).not.toHaveBeenCalled()
67
+ await jest.advanceTimersByTimeAsync(299)
68
+ expect(tick).not.toHaveBeenCalled()
69
+ await jest.advanceTimersByTimeAsync(1)
70
+ await expect(p).resolves.toBe(true)
71
+ expect(tick).toHaveBeenCalledTimes(1)
72
+ jest.useRealTimers()
73
+ })
74
+
75
+ it('increases sleep interval with backoff until maxIntervalMs', async () => {
76
+ jest.useFakeTimers()
77
+ let n = 0
78
+ const tick = jest.fn().mockImplementation(async () => {
79
+ n += 1
80
+ if (n < 3) {
81
+ return { kind: 'continue' } as const
82
+ }
83
+ return { kind: 'stop', value: 'ok' } as PollTickResult<string>
84
+ })
85
+
86
+ const p = pollLoop({
87
+ tick,
88
+ intervalMs: 10,
89
+ timeoutMs: 10_000,
90
+ backoff: { factor: 100, maxIntervalMs: 500 },
91
+ })
92
+
93
+ const settled = p.then((v) => v)
94
+ await Promise.resolve()
95
+ expect(tick).toHaveBeenCalledTimes(1)
96
+
97
+ await jest.advanceTimersByTimeAsync(10)
98
+ await Promise.resolve()
99
+ expect(tick).toHaveBeenCalledTimes(2)
100
+
101
+ await jest.advanceTimersByTimeAsync(500)
102
+ await Promise.resolve()
103
+ expect(tick).toHaveBeenCalledTimes(3)
104
+
105
+ await settled
106
+ expect(await settled).toBe('ok')
107
+ jest.useRealTimers()
108
+ })
109
+ })
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Domain-agnostic poll loop (sleep + deadline + optional backoff).
3
+ * LiFi / other modules supply classification via `tick`.
4
+ */
5
+
6
+ export type PollTickResult<T> =
7
+ | { kind: 'stop'; value: T }
8
+ | { kind: 'throw'; error: Error }
9
+ | { kind: 'continue' }
10
+
11
+ export interface PollLoopBackoff {
12
+ factor: number
13
+ maxIntervalMs: number
14
+ }
15
+
16
+ export interface PollLoopOptions<T> {
17
+ tick: () => Promise<PollTickResult<T>>
18
+ /** Base delay between polls (ms), after optional {@link initialDelayMs}. */
19
+ intervalMs: number
20
+ /** Wait this long before the first {@link tick}. @default 0 */
21
+ initialDelayMs?: number
22
+ backoff?: PollLoopBackoff
23
+ timeoutMs: number
24
+ }
25
+
26
+ export class PollLoopTimeoutError extends Error {
27
+ override readonly name = 'PollLoopTimeoutError'
28
+
29
+ constructor(message: string) {
30
+ super(message)
31
+ }
32
+ }
33
+
34
+ function sleep(ms: number): Promise<void> {
35
+ return new Promise((resolve) => setTimeout(resolve, ms))
36
+ }
37
+
38
+ /**
39
+ * Repeatedly awaits `tick` until it returns `stop` (returns value), `throw` (propagates),
40
+ * or the deadline passes (throws {@link PollLoopTimeoutError}).
41
+ */
42
+ export async function pollLoop<T>(options: PollLoopOptions<T>): Promise<T> {
43
+ const {
44
+ tick,
45
+ intervalMs: baseIntervalMs,
46
+ initialDelayMs = 0,
47
+ backoff,
48
+ timeoutMs,
49
+ } = options
50
+
51
+ const deadline = Date.now() + timeoutMs
52
+ let interval = baseIntervalMs
53
+
54
+ if (initialDelayMs > 0) {
55
+ await sleep(initialDelayMs)
56
+ }
57
+
58
+ while (Date.now() < deadline) {
59
+ const result = await tick()
60
+
61
+ // Check deadline again after tick completes (tick may have taken a long time)
62
+ if (Date.now() >= deadline && result.kind === 'continue') {
63
+ break
64
+ }
65
+
66
+ if (result.kind === 'stop') {
67
+ return result.value
68
+ }
69
+ if (result.kind === 'throw') {
70
+ throw result.error
71
+ }
72
+
73
+ const remaining = deadline - Date.now()
74
+ if (remaining <= 0) {
75
+ break
76
+ }
77
+
78
+ const sleepMs = Math.min(interval, Math.max(0, remaining))
79
+ await sleep(sleepMs)
80
+
81
+ if (backoff) {
82
+ interval = Math.min(interval * backoff.factor, backoff.maxIntervalMs)
83
+ }
84
+ }
85
+
86
+ throw new PollLoopTimeoutError(`pollLoop timed out after ${timeoutMs}ms`)
87
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * @jest-environment node
3
+ */
4
+
5
+ import { stripStalePlanningNonceIfJsonObject } from './stripStalePlanningNonce'
6
+
7
+ describe('stripStalePlanningNonceIfJsonObject', () => {
8
+ it('removes nonce from a flat tx object', () => {
9
+ const tx = { from: '0xa', to: '0xb', nonce: '0x5', data: '0x' }
10
+ expect(stripStalePlanningNonceIfJsonObject(tx)).toEqual({
11
+ from: '0xa',
12
+ to: '0xb',
13
+ data: '0x',
14
+ })
15
+ })
16
+
17
+ it('unwraps single-key wrapper then strips nonce', () => {
18
+ const wrapped = {
19
+ transaction: { from: '0xa', nonce: 1, value: '0x0' },
20
+ }
21
+ expect(stripStalePlanningNonceIfJsonObject(wrapped)).toEqual({
22
+ from: '0xa',
23
+ value: '0x0',
24
+ })
25
+ })
26
+
27
+ it('parses JSON string and strips nonce', () => {
28
+ const s = JSON.stringify({ to: '0xt', nonce: '2', gas: '21000' })
29
+ expect(stripStalePlanningNonceIfJsonObject(s)).toEqual({
30
+ to: '0xt',
31
+ gas: '21000',
32
+ })
33
+ })
34
+
35
+ it('returns opaque string on parse error', () => {
36
+ expect(stripStalePlanningNonceIfJsonObject('not-json')).toBe('not-json')
37
+ })
38
+ })
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Removes a stale `nonce` from EVM tx payloads planned ahead of signing so the
3
+ * signer can fetch a fresh nonce at broadcast time (multi-step Yield / LiFi).
4
+ */
5
+
6
+ function unwrapSingleKeyTransactionObject(
7
+ obj: Record<string, unknown>,
8
+ ): Record<string, unknown> {
9
+ const keys = Object.keys(obj)
10
+ if (keys.length !== 1) {
11
+ return obj
12
+ }
13
+
14
+ const firstKey = keys[0]
15
+ const inner = obj[firstKey]
16
+
17
+ // Type guard: only unwrap if inner value is a plain object
18
+ if (
19
+ typeof inner === 'object' &&
20
+ inner !== null &&
21
+ !Array.isArray(inner)
22
+ ) {
23
+ return inner as Record<string, unknown>
24
+ }
25
+
26
+ return obj
27
+ }
28
+
29
+ /**
30
+ * If the payload is JSON (object or JSON string), drop `nonce` from the inner tx object.
31
+ * Otherwise returns the input unchanged.
32
+ */
33
+ export function stripStalePlanningNonceIfJsonObject(
34
+ unsigned: string | Record<string, unknown>,
35
+ ): unknown {
36
+ if (
37
+ typeof unsigned === 'object' &&
38
+ unsigned !== null &&
39
+ !Array.isArray(unsigned)
40
+ ) {
41
+ const spread = { ...unsigned } as Record<string, unknown>
42
+ const inner = unwrapSingleKeyTransactionObject(spread)
43
+ const { nonce: _staleNonce, ...rest } = inner
44
+ return rest
45
+ }
46
+ if (typeof unsigned === 'string') {
47
+ try {
48
+ const parsed = JSON.parse(unsigned) as unknown
49
+ // Validate parsed result is a plain object
50
+ if (
51
+ typeof parsed === 'object' &&
52
+ parsed !== null &&
53
+ !Array.isArray(parsed)
54
+ ) {
55
+ const inner = unwrapSingleKeyTransactionObject(
56
+ parsed as Record<string, unknown>,
57
+ )
58
+ const { nonce: _staleNonce, ...rest } = inner
59
+ return rest
60
+ }
61
+ } catch {
62
+ return unsigned
63
+ }
64
+ }
65
+ return unsigned
66
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * @jest-environment node
3
+ */
4
+
5
+ import { waitForEvmOrUserOpConfirmation } from './waitForEvmOrUserOpConfirmation'
6
+
7
+ describe('waitForEvmOrUserOpConfirmation', () => {
8
+ beforeEach(() => {
9
+ jest.useFakeTimers()
10
+ })
11
+
12
+ afterEach(() => {
13
+ jest.useRealTimers()
14
+ })
15
+
16
+ it('returns false on timeout when receipt never appears', async () => {
17
+ const request = jest.fn().mockImplementation(async () => null)
18
+
19
+ const done = waitForEvmOrUserOpConfirmation('0x1', 'eip155:1', request, {
20
+ pollIntervalMs: 1_000,
21
+ timeoutMs: 2_500,
22
+ })
23
+
24
+ await jest.advanceTimersByTimeAsync(3_100)
25
+
26
+ await expect(done).resolves.toBe(false)
27
+ expect(request.mock.calls.some((c) => c[0] === 'eth_getTransactionReceipt')).toBe(
28
+ true,
29
+ )
30
+ })
31
+ })
@@ -0,0 +1,194 @@
1
+ import { sdkLogger } from '../logger'
2
+
3
+ import {
4
+ pollLoop,
5
+ PollLoopTimeoutError,
6
+ type PollTickResult,
7
+ } from './pollLoop'
8
+ import type { EvmRequestFn } from './waitForEvmTxConfirmation'
9
+
10
+ const LOG_PREFIX = '[Portal.waitForConfirmation]'
11
+
12
+ type ConfirmationMode = 'auto' | 'tx' | 'userOp'
13
+ type ReceiptState = 'not_found' | 'pending' | 'success' | 'failed'
14
+
15
+ interface EvmTxReceiptLike {
16
+ blockNumber?: string | null
17
+ status?: string | number | null
18
+ }
19
+
20
+ interface UserOpReceiptLike {
21
+ success?: boolean | null
22
+ receipt?: {
23
+ blockNumber?: string | null
24
+ status?: string | number | null
25
+ } | null
26
+ }
27
+
28
+ function parseStatusToOutcome(status: unknown): 'success' | 'failed' | 'unknown' {
29
+ if (typeof status === 'boolean') return status ? 'success' : 'failed'
30
+ if (typeof status === 'number') return status === 1 ? 'success' : 'failed'
31
+ if (typeof status === 'string') {
32
+ const normalized = status.trim().toLowerCase()
33
+ if (normalized === '0x1' || normalized === '1') return 'success'
34
+ if (normalized === '0x0' || normalized === '0') return 'failed'
35
+ }
36
+ return 'unknown'
37
+ }
38
+
39
+ function parseTxReceiptState(raw: unknown): ReceiptState {
40
+ if (raw == null || typeof raw !== 'object') return 'not_found'
41
+ const receipt = raw as EvmTxReceiptLike
42
+
43
+ if (receipt.blockNumber == null) return 'pending'
44
+
45
+ const outcome = parseStatusToOutcome(receipt.status)
46
+ if (outcome === 'success') return 'success'
47
+ if (outcome === 'failed') return 'failed'
48
+
49
+ return 'pending'
50
+ }
51
+
52
+ function parseUserOpReceiptState(raw: unknown): ReceiptState {
53
+ if (raw == null || typeof raw !== 'object') return 'not_found'
54
+ const receipt = raw as UserOpReceiptLike
55
+
56
+ if (typeof receipt.success === 'boolean') {
57
+ return receipt.success ? 'success' : 'failed'
58
+ }
59
+
60
+ const nested = receipt.receipt
61
+ if (nested == null) return 'pending'
62
+ if (nested.blockNumber == null) return 'pending'
63
+
64
+ const outcome = parseStatusToOutcome(nested.status)
65
+ if (outcome === 'success') return 'success'
66
+ if (outcome === 'failed') return 'failed'
67
+
68
+ return 'pending'
69
+ }
70
+
71
+ function isMethodNotSupportedError(error: unknown): boolean {
72
+ const message =
73
+ error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase()
74
+
75
+ return (
76
+ message.includes('method not found') ||
77
+ message.includes('does not exist/is not available') ||
78
+ message.includes('-32601') ||
79
+ message.includes('eth_getuseroperationreceipt')
80
+ )
81
+ }
82
+
83
+ export interface WaitForEvmOrUserOpConfirmationOptions {
84
+ pollIntervalMs?: number
85
+ timeoutMs?: number
86
+ onTimeout?: 'resolve_false' | 'throw'
87
+ lockModeAfterDetection?: boolean
88
+ }
89
+
90
+ export async function waitForEvmOrUserOpConfirmation(
91
+ hash: string,
92
+ network: string,
93
+ request: EvmRequestFn,
94
+ options: WaitForEvmOrUserOpConfirmationOptions = {},
95
+ ): Promise<boolean> {
96
+ const {
97
+ pollIntervalMs = 4_000,
98
+ timeoutMs = 300_000,
99
+ onTimeout = 'resolve_false',
100
+ lockModeAfterDetection = true,
101
+ } = options
102
+
103
+ let mode: ConfirmationMode = 'auto'
104
+ let userOpMethodSupported = true
105
+
106
+ sdkLogger.debug(`${LOG_PREFIX} waiting for tx/userOp confirmation`, {
107
+ hash,
108
+ network,
109
+ pollIntervalMs,
110
+ timeoutMs,
111
+ mode,
112
+ })
113
+
114
+ const tick = async (): Promise<PollTickResult<boolean>> => {
115
+ if (mode !== 'userOp') {
116
+ try {
117
+ const txRaw = await request('eth_getTransactionReceipt', [hash], network)
118
+ const txState = parseTxReceiptState(txRaw)
119
+
120
+ if (txState === 'success') return { kind: 'stop', value: true }
121
+ if (txState === 'failed') return { kind: 'stop', value: false }
122
+
123
+ if (lockModeAfterDetection && mode === 'auto' && txState === 'pending') {
124
+ mode = 'tx'
125
+ sdkLogger.debug(`${LOG_PREFIX} mode locked`, { hash, network, mode })
126
+ }
127
+ } catch (error) {
128
+ sdkLogger.warn(`${LOG_PREFIX} tx receipt poll transient error`, {
129
+ hash,
130
+ network,
131
+ error: error instanceof Error ? error.message : String(error),
132
+ })
133
+ }
134
+ }
135
+
136
+ if (mode !== 'tx' && userOpMethodSupported) {
137
+ try {
138
+ const userOpRaw = await request(
139
+ 'eth_getUserOperationReceipt',
140
+ [hash],
141
+ network,
142
+ )
143
+ const userOpState = parseUserOpReceiptState(userOpRaw)
144
+
145
+ if (userOpState === 'success') return { kind: 'stop', value: true }
146
+ if (userOpState === 'failed') return { kind: 'stop', value: false }
147
+
148
+ if (
149
+ lockModeAfterDetection &&
150
+ mode === 'auto' &&
151
+ userOpState === 'pending'
152
+ ) {
153
+ mode = 'userOp'
154
+ sdkLogger.debug(`${LOG_PREFIX} mode locked`, { hash, network, mode })
155
+ }
156
+ } catch (error) {
157
+ if (isMethodNotSupportedError(error)) {
158
+ userOpMethodSupported = false
159
+ sdkLogger.debug(
160
+ `${LOG_PREFIX} eth_getUserOperationReceipt unsupported; disabling userOp polling`,
161
+ { hash, network },
162
+ )
163
+ } else {
164
+ sdkLogger.warn(`${LOG_PREFIX} userOp receipt poll transient error`, {
165
+ hash,
166
+ network,
167
+ error: error instanceof Error ? error.message : String(error),
168
+ })
169
+ }
170
+ }
171
+ }
172
+
173
+ return { kind: 'continue' }
174
+ }
175
+
176
+ try {
177
+ return await pollLoop<boolean>({
178
+ tick,
179
+ intervalMs: pollIntervalMs,
180
+ initialDelayMs: 0,
181
+ timeoutMs,
182
+ })
183
+ } catch (error) {
184
+ if (error instanceof PollLoopTimeoutError) {
185
+ const msg = `${LOG_PREFIX} timeout after ${timeoutMs}ms waiting for confirmation on ${hash} (${network}).`
186
+ if (onTimeout === 'throw') {
187
+ throw new Error(`${msg} Confirmation not reached.`)
188
+ }
189
+ sdkLogger.warn(`${msg} Returning false (resolve_false).`)
190
+ return false
191
+ }
192
+ throw error
193
+ }
194
+ }
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Shared EVM transaction confirmation via `eth_getTransactionReceipt` + {@link pollLoop}.
3
+ *
4
+ * **Use in each domain:**
5
+ * - **Yield** (multi-step): Prefer {@link Portal.waitForConfirmation} (returns `false` on soft timeout)
6
+ * or standalone `pollForEvmReceipt` (same engine).
7
+ * - **LiFi** (`tradeAsset`): Use `onTimeout: 'throw'` with the same RPC `request` as Portal's
8
+ * gateway so a stuck tx fails before bridge status polling.
9
+ * - **Legacy swaps** (`@portal-hq/swaps`): Call `portal.waitForConfirmation(txHash, caip2)` after
10
+ * `eth_sendTransaction` so "submitted" reflects chain inclusion when the network is known.
11
+ *
12
+ * Domain-specific orchestration (LiFi status, Yield multi-tx, route steps) stays outside this module.
13
+ */
14
+
15
+ import { sdkLogger } from '../logger'
16
+
17
+ import {
18
+ pollLoop,
19
+ PollLoopTimeoutError,
20
+ type PollTickResult,
21
+ } from './pollLoop'
22
+
23
+ const LOG_PREFIX = '[Portal.evmReceipt]'
24
+
25
+ /** Shape of the relevant fields in an eth_getTransactionReceipt response. */
26
+ interface EvmReceipt {
27
+ blockNumber: string | null
28
+ /** '0x1' = success, '0x0' = reverted, absent on pending txs */
29
+ status?: string
30
+ }
31
+
32
+ /** Minimal RPC: any implementation of JSON-RPC `eth_getTransactionReceipt`. */
33
+ export type EvmRequestFn = (
34
+ method: string,
35
+ params: unknown[],
36
+ network: string,
37
+ ) => Promise<unknown>
38
+
39
+ export type WaitForEvmTxConfirmationOnTimeout = 'resolve_false' | 'throw'
40
+
41
+ export interface WaitForEvmTxConfirmationOptions {
42
+ pollIntervalMs?: number
43
+ timeoutMs?: number
44
+ /**
45
+ * Yield / `Portal.waitForConfirmation`: `resolve_false` (optimistic continuation).
46
+ * LiFi default waiter: `throw` so `tradeAsset` does not poll bridge status after a timed-out tx.
47
+ * @default 'resolve_false'
48
+ */
49
+ onTimeout?: WaitForEvmTxConfirmationOnTimeout
50
+ }
51
+
52
+ interface EvmReceiptPollTickContext {
53
+ txHash: string
54
+ network: string
55
+ request: EvmRequestFn
56
+ pollIntervalMs: number
57
+ }
58
+
59
+ async function evmReceiptPollTick(
60
+ ctx: EvmReceiptPollTickContext,
61
+ ): Promise<PollTickResult<boolean>> {
62
+ const { txHash, network, request, pollIntervalMs } = ctx
63
+
64
+ try {
65
+ const raw = await request('eth_getTransactionReceipt', [txHash], network)
66
+ const receipt = raw as EvmReceipt | null
67
+
68
+ if (receipt?.blockNumber != null) {
69
+ if (receipt.status === '0x0') {
70
+ return {
71
+ kind: 'throw',
72
+ error: new Error(
73
+ `${LOG_PREFIX} Transaction ${txHash} was reverted on-chain (status 0x0). ` +
74
+ 'The transaction did not succeed.',
75
+ ),
76
+ }
77
+ }
78
+ sdkLogger.debug(`${LOG_PREFIX} receipt confirmed`, {
79
+ txHash,
80
+ network,
81
+ blockNumber: receipt.blockNumber,
82
+ status: receipt.status,
83
+ })
84
+ return { kind: 'stop', value: true }
85
+ }
86
+
87
+ sdkLogger.debug(`${LOG_PREFIX} not yet mined, retrying`, { txHash, network })
88
+ return { kind: 'continue' }
89
+ } catch (error) {
90
+ if (error instanceof Error && error.message.includes('reverted')) {
91
+ return { kind: 'throw', error }
92
+ }
93
+ sdkLogger.warn(
94
+ `${LOG_PREFIX} transient error polling receipt for ${txHash} (${network}), will retry in ${pollIntervalMs}ms:`,
95
+ error,
96
+ )
97
+ return { kind: 'continue' }
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Poll until the tx is mined with success (`0x1`), reverted (`0x0` → throw), or timeout.
103
+ * Pending receipts and transient RPC errors retry with warn logs; logs always include `txHash` / `network` where relevant.
104
+ */
105
+ export async function waitForEvmTxConfirmation(
106
+ txHash: string,
107
+ network: string,
108
+ request: EvmRequestFn,
109
+ options: WaitForEvmTxConfirmationOptions = {},
110
+ ): Promise<boolean> {
111
+ const {
112
+ pollIntervalMs = 4_000,
113
+ timeoutMs = 900_000,
114
+ onTimeout = 'resolve_false',
115
+ } = options
116
+
117
+ sdkLogger.debug(`${LOG_PREFIX} waiting for receipt`, {
118
+ txHash,
119
+ network,
120
+ pollIntervalMs,
121
+ timeoutMs,
122
+ onTimeout,
123
+ })
124
+
125
+ const tickCtx: EvmReceiptPollTickContext = {
126
+ txHash,
127
+ network,
128
+ request,
129
+ pollIntervalMs,
130
+ }
131
+
132
+ try {
133
+ return await pollLoop<boolean>({
134
+ tick: () => evmReceiptPollTick(tickCtx),
135
+ intervalMs: pollIntervalMs,
136
+ initialDelayMs: 0,
137
+ timeoutMs,
138
+ })
139
+ } catch (error) {
140
+ if (error instanceof PollLoopTimeoutError) {
141
+ const msg =
142
+ `${LOG_PREFIX} timeout after ${timeoutMs}ms waiting for receipt on ${txHash} (${network}).`
143
+ if (onTimeout === 'throw') {
144
+ throw new Error(
145
+ `${msg} Transaction may still confirm; aborting this wait.`,
146
+ )
147
+ }
148
+ sdkLogger.warn(
149
+ `${msg} Transaction may still confirm. Proceeding optimistically.`,
150
+ )
151
+ return false
152
+ }
153
+ throw error
154
+ }
155
+ }