@portal-hq/web 3.13.2 → 3.14.0-alpha.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 +330 -0
  16. package/lib/commonjs/integrations/yield/yieldxyz.js +517 -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 +325 -0
  44. package/lib/esm/integrations/yield/yieldxyz.js +517 -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 +403 -0
  74. package/src/integrations/yield/yieldxyz.ts +740 -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 +186 -0
  95. package/src/shared/types/zero-x.ts +66 -0
  96. package/types.d.ts +6 -0
@@ -119,4 +119,255 @@ describe('Delegations', () => {
119
119
  ).rejects.toThrow('Test error')
120
120
  })
121
121
  })
122
+
123
+ describe('approveAndSubmit, revokeAndSubmit, transferAndSubmit', () => {
124
+ const mockSignAndSend = jest.fn()
125
+
126
+ beforeEach(() => {
127
+ mockSignAndSend.mockResolvedValue('0xtxhash')
128
+ })
129
+
130
+ it('throws when signAndSendTransaction is not configured', async () => {
131
+ const d = new Delegations({ mpc })
132
+
133
+ await expect(d.approveAndSubmit(mockEVMApproveRequest)).rejects.toThrow(
134
+ '[Delegations] No signer configured. Call setSignAndSendTransaction() on the instance or pass signAndSendTransaction in options.',
135
+ )
136
+ await expect(d.revokeAndSubmit(mockEVMRevokeRequest)).rejects.toThrow(
137
+ '[Delegations] No signer configured. Call setSignAndSendTransaction() on the instance or pass signAndSendTransaction in options.',
138
+ )
139
+ await expect(d.transferAndSubmit(mockTransferFromRequest)).rejects.toThrow(
140
+ '[Delegations] No signer configured. Call setSignAndSendTransaction() on the instance or pass signAndSendTransaction in options.',
141
+ )
142
+ })
143
+
144
+ it('approveAndSubmit calls MPC then signAndSend per transaction', async () => {
145
+ jest.spyOn(mpc, 'delegationsApprove').mockResolvedValue(mockEVMApproveResponse)
146
+ const d = new Delegations({
147
+ mpc,
148
+ signAndSendTransaction: mockSignAndSend,
149
+ })
150
+
151
+ const result = await d.approveAndSubmit(mockEVMApproveRequest)
152
+
153
+ expect(mpc.delegationsApprove).toHaveBeenCalledWith(mockEVMApproveRequest)
154
+ expect(mockSignAndSend).toHaveBeenCalledTimes(1)
155
+ expect(mockSignAndSend).toHaveBeenCalledWith(
156
+ mockEVMApproveResponse.transactions![0],
157
+ mockEVMApproveRequest.chain,
158
+ )
159
+ expect(result).toEqual({ hashes: ['0xtxhash'] })
160
+ })
161
+
162
+ it('revokeAndSubmit calls delegationsRevoke then signAndSend per transaction', async () => {
163
+ jest.spyOn(mpc, 'delegationsRevoke').mockResolvedValue(mockEVMRevokeResponse)
164
+ const d = new Delegations({
165
+ mpc,
166
+ signAndSendTransaction: mockSignAndSend,
167
+ })
168
+
169
+ const result = await d.revokeAndSubmit(mockEVMRevokeRequest)
170
+
171
+ expect(mpc.delegationsRevoke).toHaveBeenCalledWith(mockEVMRevokeRequest)
172
+ expect(mockSignAndSend).toHaveBeenCalledTimes(1)
173
+ expect(mockSignAndSend).toHaveBeenCalledWith(
174
+ mockEVMRevokeResponse.transactions![0],
175
+ mockEVMRevokeRequest.chain,
176
+ )
177
+ expect(result).toEqual({ hashes: ['0xtxhash'] })
178
+ })
179
+
180
+ it('transferAndSubmit calls delegationsTransferFrom then signAndSend per transaction', async () => {
181
+ jest
182
+ .spyOn(mpc, 'delegationsTransferFrom')
183
+ .mockResolvedValue(mockTransferFromResponse)
184
+ const d = new Delegations({
185
+ mpc,
186
+ signAndSendTransaction: mockSignAndSend,
187
+ })
188
+
189
+ const result = await d.transferAndSubmit(mockTransferFromRequest)
190
+
191
+ expect(mpc.delegationsTransferFrom).toHaveBeenCalledWith(
192
+ mockTransferFromRequest,
193
+ )
194
+ expect(mockSignAndSend).toHaveBeenCalledTimes(1)
195
+ expect(mockSignAndSend).toHaveBeenCalledWith(
196
+ mockTransferFromResponse.transactions![0],
197
+ mockTransferFromRequest.chain,
198
+ )
199
+ expect(result).toEqual({ hashes: ['0xtxhash'] })
200
+ })
201
+
202
+ it('uses encodedTransactions when transactions is empty', async () => {
203
+ const response = {
204
+ transactions: [],
205
+ encodedTransactions: ['solTxPayload'],
206
+ metadata: mockEVMApproveResponse.metadata,
207
+ }
208
+ jest.spyOn(mpc, 'delegationsApprove').mockResolvedValue(response)
209
+ const d = new Delegations({
210
+ mpc,
211
+ signAndSendTransaction: mockSignAndSend,
212
+ })
213
+
214
+ await d.approveAndSubmit(mockEVMApproveRequest)
215
+
216
+ expect(mockSignAndSend).toHaveBeenCalledWith(
217
+ 'solTxPayload',
218
+ mockEVMApproveRequest.chain,
219
+ )
220
+ })
221
+
222
+ it('invokes onProgress for each transaction', async () => {
223
+ jest.spyOn(mpc, 'delegationsApprove').mockResolvedValue(mockEVMApproveResponse)
224
+ const onProgress = jest.fn()
225
+ const d = new Delegations({
226
+ mpc,
227
+ signAndSendTransaction: mockSignAndSend,
228
+ })
229
+
230
+ await d.approveAndSubmit(mockEVMApproveRequest, { onProgress })
231
+
232
+ expect(onProgress).toHaveBeenCalledTimes(2)
233
+ expect(onProgress).toHaveBeenNthCalledWith(1, {
234
+ step: 'signing',
235
+ index: 0,
236
+ total: 1,
237
+ })
238
+ expect(onProgress).toHaveBeenNthCalledWith(2, {
239
+ step: 'submitted',
240
+ index: 0,
241
+ total: 1,
242
+ hash: '0xtxhash',
243
+ })
244
+ })
245
+
246
+ it('throws when response has no transactions', async () => {
247
+ jest.spyOn(mpc, 'delegationsApprove').mockResolvedValue({
248
+ metadata: mockEVMApproveResponse.metadata,
249
+ })
250
+ const d = new Delegations({
251
+ mpc,
252
+ signAndSendTransaction: mockSignAndSend,
253
+ })
254
+
255
+ await expect(d.approveAndSubmit(mockEVMApproveRequest)).rejects.toThrow(
256
+ 'No transactions in delegation response.',
257
+ )
258
+ expect(mockSignAndSend).not.toHaveBeenCalled()
259
+ })
260
+
261
+ it('submits multiple EVM transactions in order', async () => {
262
+ const txA = { ...(
263
+ mockEVMApproveResponse.transactions as NonNullable<
264
+ typeof mockEVMApproveResponse.transactions
265
+ >
266
+ )[0]! }
267
+ const txB = { ...txA, data: '0xbbbb' }
268
+ jest.spyOn(mpc, 'delegationsApprove').mockResolvedValue({
269
+ ...mockEVMApproveResponse,
270
+ transactions: [txA, txB],
271
+ })
272
+ const d = new Delegations({
273
+ mpc,
274
+ signAndSendTransaction: mockSignAndSend,
275
+ })
276
+ mockSignAndSend.mockResolvedValueOnce('0xh1').mockResolvedValueOnce('0xh2')
277
+
278
+ const result = await d.approveAndSubmit(mockEVMApproveRequest)
279
+
280
+ expect(mockSignAndSend).toHaveBeenCalledTimes(2)
281
+ expect(mockSignAndSend).toHaveBeenNthCalledWith(1, txA, mockEVMApproveRequest.chain)
282
+ expect(mockSignAndSend).toHaveBeenNthCalledWith(2, txB, mockEVMApproveRequest.chain)
283
+ expect(result.hashes).toEqual(['0xh1', '0xh2'])
284
+ })
285
+
286
+ it('propagates error after first tx succeeds (partial submit)', async () => {
287
+ const txA = { ...(
288
+ mockEVMApproveResponse.transactions as NonNullable<
289
+ typeof mockEVMApproveResponse.transactions
290
+ >
291
+ )[0]! }
292
+ const txB = { ...txA, data: '0xbbbb' }
293
+ jest.spyOn(mpc, 'delegationsApprove').mockResolvedValue({
294
+ ...mockEVMApproveResponse,
295
+ transactions: [txA, txB],
296
+ })
297
+ const d = new Delegations({
298
+ mpc,
299
+ signAndSendTransaction: mockSignAndSend,
300
+ })
301
+ mockSignAndSend
302
+ .mockResolvedValueOnce('0xh1')
303
+ .mockRejectedValueOnce(new Error('second tx failed'))
304
+
305
+ await expect(d.approveAndSubmit(mockEVMApproveRequest)).rejects.toThrow(
306
+ 'second tx failed',
307
+ )
308
+ expect(mockSignAndSend).toHaveBeenCalledTimes(2)
309
+ })
310
+
311
+ it('per-call signAndSendTransaction overrides constructor signer', async () => {
312
+ jest.spyOn(mpc, 'delegationsApprove').mockResolvedValue(mockEVMApproveResponse)
313
+ const instanceSigner = jest.fn().mockResolvedValue('0xinstance')
314
+ const perCallSigner = jest.fn().mockResolvedValue('0xpercall')
315
+ const d = new Delegations({
316
+ mpc,
317
+ signAndSendTransaction: instanceSigner,
318
+ })
319
+
320
+ await d.approveAndSubmit(mockEVMApproveRequest, {
321
+ signAndSendTransaction: perCallSigner,
322
+ })
323
+
324
+ expect(instanceSigner).not.toHaveBeenCalled()
325
+ expect(perCallSigner).toHaveBeenCalledTimes(1)
326
+ })
327
+
328
+ it('throws on whitespace-only hash from signer', async () => {
329
+ jest.spyOn(mpc, 'delegationsApprove').mockResolvedValue(mockEVMApproveResponse)
330
+ mockSignAndSend.mockResolvedValue(' ')
331
+ const d = new Delegations({
332
+ mpc,
333
+ signAndSendTransaction: mockSignAndSend,
334
+ })
335
+
336
+ await expect(d.approveAndSubmit(mockEVMApproveRequest)).rejects.toThrow(
337
+ 'Invalid transaction hash',
338
+ )
339
+ })
340
+
341
+ it('prefers transactions over encodedTransactions when both non-empty', async () => {
342
+ const evmTx = mockEVMApproveResponse.transactions![0]!
343
+ jest.spyOn(mpc, 'delegationsApprove').mockResolvedValue({
344
+ transactions: [evmTx],
345
+ encodedTransactions: ['should-not-use'],
346
+ metadata: mockEVMApproveResponse.metadata,
347
+ })
348
+ const d = new Delegations({
349
+ mpc,
350
+ signAndSendTransaction: mockSignAndSend,
351
+ })
352
+
353
+ await d.approveAndSubmit(mockEVMApproveRequest)
354
+
355
+ expect(mockSignAndSend).toHaveBeenCalledWith(
356
+ evmTx,
357
+ mockEVMApproveRequest.chain,
358
+ )
359
+ })
360
+
361
+ it('setSignAndSendTransaction enables submit after construction', async () => {
362
+ jest.spyOn(mpc, 'delegationsApprove').mockResolvedValue(mockEVMApproveResponse)
363
+ const d = new Delegations({ mpc })
364
+ const late = jest.fn().mockResolvedValue('0xlate')
365
+ d.setSignAndSendTransaction(late)
366
+
367
+ const result = await d.approveAndSubmit(mockEVMApproveRequest)
368
+
369
+ expect(late).toHaveBeenCalledTimes(1)
370
+ expect(result).toEqual({ hashes: ['0xlate'] })
371
+ })
372
+ })
122
373
  })
@@ -8,6 +8,7 @@ import type {
8
8
  DelegationStatusResponse,
9
9
  TransferFromRequest,
10
10
  TransferFromResponse,
11
+ DelegationSubmitProgress,
11
12
  } from '../../shared/types'
12
13
 
13
14
  export interface IPortalDelegationsApi {
@@ -19,13 +20,68 @@ export interface IPortalDelegationsApi {
19
20
  transferFrom(params: TransferFromRequest): Promise<TransferFromResponse>
20
21
  }
21
22
 
22
- export type IDelegations = IPortalDelegationsApi
23
+ const DELEGATIONS_SUBMIT_CONFIG_ERROR =
24
+ '[Delegations] No signer configured. Call setSignAndSendTransaction() on the instance or pass signAndSendTransaction in options.'
25
+
26
+ /** Optional callback for sign-and-submit flows. Invoked once per transaction. */
27
+ export interface DelegationSubmitOptions {
28
+ signAndSendTransaction?: (
29
+ transaction: unknown,
30
+ chainId: string,
31
+ ) => Promise<string>
32
+ onProgress?: (event: DelegationSubmitProgress) => void
33
+ }
34
+
35
+ /**
36
+ * Full delegations surface exposed on {@link Portal.delegations}, including sign-and-submit helpers.
37
+ */
38
+ export interface IDelegations extends IPortalDelegationsApi {
39
+ /** Sets the default signer used by approveAndSubmit, revokeAndSubmit, and transferAndSubmit when options omit signAndSendTransaction. */
40
+ setSignAndSendTransaction(
41
+ fn: (transaction: unknown, chainId: string) => Promise<string>,
42
+ ): void
43
+ approveAndSubmit(
44
+ params: ApproveDelegationRequest,
45
+ options?: DelegationSubmitOptions,
46
+ ): Promise<{ hashes: string[] }>
47
+ revokeAndSubmit(
48
+ params: RevokeDelegationRequest,
49
+ options?: DelegationSubmitOptions,
50
+ ): Promise<{ hashes: string[] }>
51
+ transferAndSubmit(
52
+ params: TransferFromRequest,
53
+ options?: DelegationSubmitOptions,
54
+ ): Promise<{ hashes: string[] }>
55
+ }
56
+
57
+ export interface DelegationsOptions {
58
+ mpc: Mpc
59
+ /**
60
+ * When set, enables {@link Delegations.approveAndSubmit}, {@link Delegations.revokeAndSubmit},
61
+ * and {@link Delegations.transferAndSubmit}. `Portal` passes this by default using the iframe provider.
62
+ */
63
+ signAndSendTransaction?: (
64
+ transaction: unknown,
65
+ chainId: string,
66
+ ) => Promise<string>
67
+ }
23
68
 
24
69
  export default class Delegations implements IDelegations {
25
- private mpc: Mpc
70
+ private readonly mpc: Mpc
71
+ private signAndSendTransaction?: (
72
+ transaction: unknown,
73
+ chainId: string,
74
+ ) => Promise<string>
26
75
 
27
- constructor({ mpc }: { mpc: Mpc }) {
28
- this.mpc = mpc
76
+ constructor(options: DelegationsOptions) {
77
+ this.mpc = options.mpc
78
+ this.signAndSendTransaction = options.signAndSendTransaction
79
+ }
80
+
81
+ public setSignAndSendTransaction(
82
+ fn: (transaction: unknown, chainId: string) => Promise<string>,
83
+ ): void {
84
+ this.signAndSendTransaction = fn
29
85
  }
30
86
 
31
87
  /**
@@ -75,4 +131,146 @@ export default class Delegations implements IDelegations {
75
131
  ): Promise<TransferFromResponse> {
76
132
  return this.mpc?.delegationsTransferFrom(params)
77
133
  }
134
+
135
+ /**
136
+ * Approves a delegation, then signs and broadcasts each returned transaction via
137
+ * {@link DelegationsOptions.signAndSendTransaction}.
138
+ *
139
+ * Requires `signAndSendTransaction` (provided by default on `Portal.delegations`).
140
+ * Confirming transactions on-chain remains the application's responsibility.
141
+ *
142
+ * @param params - Same as {@link Delegations.approve}
143
+ * @param options - Optional progress callbacks
144
+ * @returns Transaction hashes in submission order
145
+ */
146
+ public async approveAndSubmit(
147
+ params: Parameters<IPortalDelegationsApi['approve']>[0],
148
+ options?: DelegationSubmitOptions,
149
+ ): Promise<{ hashes: string[] }> {
150
+ const signAndSend =
151
+ options?.signAndSendTransaction ?? this.signAndSendTransaction
152
+ if (!signAndSend) {
153
+ throw new Error(DELEGATIONS_SUBMIT_CONFIG_ERROR)
154
+ }
155
+ const response = await this.approve(params)
156
+ return this.executeAndTrack(
157
+ response,
158
+ params.chain,
159
+ signAndSend,
160
+ options?.onProgress,
161
+ )
162
+ }
163
+
164
+ /**
165
+ * Revokes a delegation, then signs and broadcasts each returned transaction.
166
+ *
167
+ * Requires `signAndSendTransaction` (provided by default on `Portal.delegations`).
168
+ *
169
+ * @param params - Same as {@link Delegations.revoke}
170
+ * @param options - Optional progress callbacks
171
+ * @returns Transaction hashes in submission order
172
+ */
173
+ public async revokeAndSubmit(
174
+ params: Parameters<IPortalDelegationsApi['revoke']>[0],
175
+ options?: DelegationSubmitOptions,
176
+ ): Promise<{ hashes: string[] }> {
177
+ const signAndSend =
178
+ options?.signAndSendTransaction ?? this.signAndSendTransaction
179
+ if (!signAndSend) {
180
+ throw new Error(DELEGATIONS_SUBMIT_CONFIG_ERROR)
181
+ }
182
+ const response = await this.revoke(params)
183
+ return this.executeAndTrack(
184
+ response,
185
+ params.chain,
186
+ signAndSend,
187
+ options?.onProgress,
188
+ )
189
+ }
190
+
191
+ /**
192
+ * Executes a delegated transfer, then signs and broadcasts each returned transaction.
193
+ *
194
+ * Requires `signAndSendTransaction` (provided by default on `Portal.delegations`).
195
+ *
196
+ * @param params - Same as {@link Delegations.transferFrom}
197
+ * @param options - Optional progress callbacks
198
+ * @returns Transaction hashes in submission order
199
+ */
200
+ public async transferAndSubmit(
201
+ params: Parameters<IPortalDelegationsApi['transferFrom']>[0],
202
+ options?: DelegationSubmitOptions,
203
+ ): Promise<{ hashes: string[] }> {
204
+ const signAndSend =
205
+ options?.signAndSendTransaction ?? this.signAndSendTransaction
206
+ if (!signAndSend) {
207
+ throw new Error(DELEGATIONS_SUBMIT_CONFIG_ERROR)
208
+ }
209
+ const response = await this.transferFrom(params)
210
+ return this.executeAndTrack(
211
+ response,
212
+ params.chain,
213
+ signAndSend,
214
+ options?.onProgress,
215
+ )
216
+ }
217
+
218
+ private normalizeToTransactionList(
219
+ response:
220
+ | ApproveDelegationResponse
221
+ | RevokeDelegationResponse
222
+ | TransferFromResponse,
223
+ ): unknown[] {
224
+ const transactions = Array.isArray(response.transactions)
225
+ ? response.transactions
226
+ : []
227
+ const encodedTransactions = Array.isArray(response.encodedTransactions)
228
+ ? response.encodedTransactions
229
+ : []
230
+ if (transactions.length > 0) {
231
+ return transactions
232
+ }
233
+ if (encodedTransactions.length > 0) {
234
+ return encodedTransactions
235
+ }
236
+ return []
237
+ }
238
+
239
+ private async executeAndTrack(
240
+ response:
241
+ | ApproveDelegationResponse
242
+ | RevokeDelegationResponse
243
+ | TransferFromResponse,
244
+ chainId: string,
245
+ signAndSend: (transaction: unknown, chainId: string) => Promise<string>,
246
+ onProgress?: (event: DelegationSubmitProgress) => void,
247
+ ): Promise<{ hashes: string[] }> {
248
+ const transactions = this.normalizeToTransactionList(response)
249
+ if (transactions.length === 0) {
250
+ throw new Error('No transactions in delegation response.')
251
+ }
252
+ const total = transactions.length
253
+ const hashes: string[] = []
254
+ for (let index = 0; index < transactions.length; index++) {
255
+ const tx = transactions[index]
256
+
257
+ // Validate transaction exists (defensive check for sparse arrays)
258
+ if (!tx) {
259
+ throw new Error(
260
+ `Transaction at index ${index} is undefined or null. This indicates a malformed API response.`,
261
+ )
262
+ }
263
+
264
+ onProgress?.({ step: 'signing', index, total })
265
+ const hash = await signAndSend(tx, chainId)
266
+ if (typeof hash !== 'string' || hash.trim().length === 0) {
267
+ throw new Error(
268
+ `Invalid transaction hash returned from signAndSendTransaction at index ${index} for chain ${chainId}.`,
269
+ )
270
+ }
271
+ hashes.push(hash)
272
+ onProgress?.({ step: 'submitted', index, total, hash })
273
+ }
274
+ return { hashes }
275
+ }
78
276
  }
@@ -63,7 +63,15 @@ describe('Noah', () => {
63
63
  destinationAddress: 'SoLAddr1111111111111111111111111111111111111',
64
64
  }
65
65
  const spy = jest.spyOn(mpc, 'initiatePayin').mockResolvedValue({
66
- data: { payinId: 'p1', bankDetails: {} },
66
+ data: {
67
+ payinId: 'p1',
68
+ bankDetails: {
69
+ paymentMethodId: 'pm-123',
70
+ paymentMethodType: 'bank_account',
71
+ accountNumber: '1234567890',
72
+ network: 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1',
73
+ },
74
+ },
67
75
  })
68
76
  await noah.initiatePayin(req)
69
77
  expect(spy).toHaveBeenCalledWith(req)
@@ -76,7 +84,7 @@ describe('Noah', () => {
76
84
  fiatCurrency: 'USD',
77
85
  }
78
86
  const spy = jest.spyOn(mpc, 'simulatePayin').mockResolvedValue({
79
- data: {},
87
+ data: { fiatDepositId: 'deposit-123' },
80
88
  })
81
89
  await noah.simulatePayin(sim)
82
90
  expect(spy).toHaveBeenCalledWith(sim)
@@ -84,7 +92,7 @@ describe('Noah', () => {
84
92
 
85
93
  it('should call mpc.getPayoutCountries', async () => {
86
94
  const spy = jest.spyOn(mpc, 'getPayoutCountries').mockResolvedValue({
87
- data: { countries: [] },
95
+ data: { countries: { US: ['USD'], MX: ['MXN', 'USD'] } },
88
96
  })
89
97
  await noah.getPayoutCountries()
90
98
  expect(spy).toHaveBeenCalled()
@@ -98,7 +106,7 @@ describe('Noah', () => {
98
106
  fiatAmount: '10',
99
107
  }
100
108
  const spy = jest.spyOn(mpc, 'getPayoutChannels').mockResolvedValue({
101
- data: [],
109
+ data: { items: [] },
102
110
  } as never)
103
111
  await noah.getPayoutChannels(req)
104
112
  expect(spy).toHaveBeenCalledWith(req)
@@ -107,7 +115,12 @@ describe('Noah', () => {
107
115
  it('should call mpc.getPayoutChannelForm', async () => {
108
116
  const spy = jest
109
117
  .spyOn(mpc, 'getPayoutChannelForm')
110
- .mockResolvedValue({ data: {} } as never)
118
+ .mockResolvedValue({
119
+ data: {
120
+ formSchema: { type: 'object', properties: {} },
121
+ formMetadata: { contentHash: 'abc123' },
122
+ },
123
+ } as never)
111
124
  await noah.getPayoutChannelForm('ch-1')
112
125
  expect(spy).toHaveBeenCalledWith('ch-1')
113
126
  })
@@ -1,17 +1,20 @@
1
1
  import Mpc from '../../mpc'
2
- import ZeroX from './zero-x'
3
- import LiFi from './lifi'
2
+ import ZeroX, { type IZeroX, type ZeroXOptions } from './zero-x'
3
+ import LiFi, { type ILiFi, type LifiOptions } from './lifi'
4
+
5
+ export type TradingOptions = LifiOptions & ZeroXOptions
6
+
7
+ export type { LifiOptions, ILiFi, ZeroXOptions, IZeroX }
4
8
 
5
9
  /**
6
- * This class is a container for the LiFi class.
7
- * In the future, Trading domain logic should be here.
10
+ * Trading integrations (LiFi, 0x).
8
11
  */
9
12
  export default class Trading {
10
13
  public lifi: LiFi
11
14
  public zeroX: ZeroX
12
15
 
13
- constructor({ mpc }: { mpc: Mpc }) {
14
- this.lifi = new LiFi({ mpc })
15
- this.zeroX = new ZeroX({ mpc })
16
+ constructor({ mpc, ...defaults }: { mpc: Mpc } & TradingOptions) {
17
+ this.lifi = new LiFi({ mpc, ...defaults })
18
+ this.zeroX = new ZeroX({ mpc, ...defaults })
16
19
  }
17
20
  }