@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.
- package/lib/commonjs/index.js +127 -9
- package/lib/commonjs/index.test.js +13 -0
- package/lib/commonjs/integrations/delegations/index.js +109 -2
- package/lib/commonjs/integrations/delegations/index.test.js +171 -0
- package/lib/commonjs/integrations/ramps/noah/index.test.js +18 -5
- package/lib/commonjs/integrations/trading/index.js +16 -5
- package/lib/commonjs/integrations/trading/lifi/index.js +297 -25
- package/lib/commonjs/integrations/trading/lifi/lifi.tradeAsset.test.js +360 -0
- package/lib/commonjs/integrations/trading/lifi/lifiStatusPoll.js +118 -0
- package/lib/commonjs/integrations/trading/lifi/lifiStatusPoll.test.js +66 -0
- package/lib/commonjs/integrations/trading/zero-x/index.js +129 -26
- package/lib/commonjs/integrations/trading/zero-x/index.test.js +163 -1
- package/lib/commonjs/integrations/yield/index.js +18 -4
- package/lib/commonjs/integrations/yield/yieldxyz.getValidators.test.js +71 -0
- package/lib/commonjs/integrations/yield/yieldxyz.highLevel.test.js +438 -0
- package/lib/commonjs/integrations/yield/yieldxyz.js +541 -1
- package/lib/commonjs/internal/pollLoop.js +64 -0
- package/lib/commonjs/internal/pollLoop.test.js +100 -0
- package/lib/commonjs/internal/stripStalePlanningNonce.js +65 -0
- package/lib/commonjs/internal/stripStalePlanningNonce.test.js +35 -0
- package/lib/commonjs/internal/waitForEvmOrUserOpConfirmation.js +155 -0
- package/lib/commonjs/internal/waitForEvmOrUserOpConfirmation.test.js +33 -0
- package/lib/commonjs/internal/waitForEvmTxConfirmation.js +104 -0
- package/lib/commonjs/internal/waitForSolanaTxConfirmation.js +106 -0
- package/lib/commonjs/internal/yieldEvmNetwork.js +60 -0
- package/lib/commonjs/mpc/index.js +116 -1
- package/lib/commonjs/provider/index.js +17 -0
- package/lib/commonjs/shared/trace/index.js +0 -1
- package/lib/esm/index.js +127 -9
- package/lib/esm/index.test.js +13 -0
- package/lib/esm/integrations/delegations/index.js +109 -2
- package/lib/esm/integrations/delegations/index.test.js +171 -0
- package/lib/esm/integrations/ramps/noah/index.test.js +18 -5
- package/lib/esm/integrations/trading/index.js +16 -5
- package/lib/esm/integrations/trading/lifi/index.js +292 -25
- package/lib/esm/integrations/trading/lifi/lifi.tradeAsset.test.js +332 -0
- package/lib/esm/integrations/trading/lifi/lifiStatusPoll.js +113 -0
- package/lib/esm/integrations/trading/lifi/lifiStatusPoll.test.js +64 -0
- package/lib/esm/integrations/trading/zero-x/index.js +129 -26
- package/lib/esm/integrations/trading/zero-x/index.test.js +141 -2
- package/lib/esm/integrations/yield/index.js +18 -4
- package/lib/esm/integrations/yield/yieldxyz.getValidators.test.js +66 -0
- package/lib/esm/integrations/yield/yieldxyz.highLevel.test.js +433 -0
- package/lib/esm/integrations/yield/yieldxyz.js +541 -1
- package/lib/esm/internal/pollLoop.js +59 -0
- package/lib/esm/internal/pollLoop.test.js +98 -0
- package/lib/esm/internal/stripStalePlanningNonce.js +61 -0
- package/lib/esm/internal/stripStalePlanningNonce.test.js +33 -0
- package/lib/esm/internal/waitForEvmOrUserOpConfirmation.js +151 -0
- package/lib/esm/internal/waitForEvmOrUserOpConfirmation.test.js +31 -0
- package/lib/esm/internal/waitForEvmTxConfirmation.js +100 -0
- package/lib/esm/internal/waitForSolanaTxConfirmation.js +102 -0
- package/lib/esm/internal/yieldEvmNetwork.js +55 -0
- package/lib/esm/mpc/index.js +116 -1
- package/lib/esm/provider/index.js +17 -0
- package/lib/esm/shared/trace/index.js +0 -1
- package/noah-types.d.ts +16 -2
- package/package.json +3 -2
- package/src/index.test.ts +15 -0
- package/src/index.ts +203 -14
- package/src/integrations/delegations/index.test.ts +251 -0
- package/src/integrations/delegations/index.ts +202 -4
- package/src/integrations/ramps/noah/index.test.ts +18 -5
- package/src/integrations/trading/index.ts +10 -7
- package/src/integrations/trading/lifi/index.ts +388 -28
- package/src/integrations/trading/lifi/lifi.tradeAsset.test.ts +436 -0
- package/src/integrations/trading/lifi/lifiStatusPoll.test.ts +74 -0
- package/src/integrations/trading/lifi/lifiStatusPoll.ts +158 -0
- package/src/integrations/trading/zero-x/index.test.ts +297 -1
- package/src/integrations/trading/zero-x/index.ts +181 -27
- package/src/integrations/yield/index.ts +24 -4
- package/src/integrations/yield/yieldxyz.getValidators.test.ts +70 -0
- package/src/integrations/yield/yieldxyz.highLevel.test.ts +536 -0
- package/src/integrations/yield/yieldxyz.ts +762 -8
- package/src/internal/pollLoop.test.ts +109 -0
- package/src/internal/pollLoop.ts +87 -0
- package/src/internal/stripStalePlanningNonce.test.ts +38 -0
- package/src/internal/stripStalePlanningNonce.ts +66 -0
- package/src/internal/waitForEvmOrUserOpConfirmation.test.ts +31 -0
- package/src/internal/waitForEvmOrUserOpConfirmation.ts +194 -0
- package/src/internal/waitForEvmTxConfirmation.ts +155 -0
- package/src/internal/waitForSolanaTxConfirmation.ts +135 -0
- package/src/internal/yieldEvmNetwork.ts +57 -0
- package/src/mpc/index.ts +142 -1
- package/src/provider/index.ts +25 -0
- package/src/shared/trace/index.ts +0 -1
- package/src/shared/types/README.md +6 -0
- package/src/shared/types/api.ts +12 -1
- package/src/shared/types/common.ts +332 -20
- package/src/shared/types/delegations.ts +10 -0
- package/src/shared/types/index.ts +1 -0
- package/src/shared/types/lifi.ts +82 -0
- package/src/shared/types/noah.ts +124 -33
- package/src/shared/types/yieldxyz.ts +193 -0
- package/src/shared/types/zero-x.ts +66 -0
- 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
|
+
}
|