@portal-hq/web 3.16.0 → 3.17.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.
- package/lib/commonjs/index.js +294 -0
- package/lib/commonjs/index.test.js +231 -0
- package/lib/commonjs/mpc/index.js +27 -1
- package/lib/commonjs/mpc/index.test.js +134 -0
- package/lib/commonjs/shared/types/accountAbstraction.js +2 -0
- package/lib/commonjs/shared/types/index.js +2 -0
- package/lib/esm/index.js +294 -0
- package/lib/esm/index.test.js +233 -2
- package/lib/esm/mpc/index.js +27 -1
- package/lib/esm/mpc/index.test.js +135 -1
- package/lib/esm/shared/types/accountAbstraction.js +1 -0
- package/lib/esm/shared/types/index.js +2 -0
- package/package.json +3 -2
- package/src/__mocks/constants.ts +93 -0
- package/src/__mocks/portal/mpc.ts +11 -0
- package/src/index.test.ts +473 -1
- package/src/index.ts +454 -0
- package/src/mpc/index.test.ts +164 -0
- package/src/mpc/index.ts +36 -1
- package/src/shared/types/accountAbstraction.ts +122 -0
- package/src/shared/types/common.ts +3 -0
- package/src/shared/types/index.ts +3 -0
package/src/index.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
import {
|
|
9
9
|
GetAssetsResponse,
|
|
10
10
|
BuiltTransaction,
|
|
11
|
+
BuiltEip155Transaction,
|
|
11
12
|
BuiltTronTransaction,
|
|
12
13
|
EvaluateTransactionOperationType,
|
|
13
14
|
EvaluateTransactionParam,
|
|
@@ -35,6 +36,14 @@ import {
|
|
|
35
36
|
type SimulatedTransaction,
|
|
36
37
|
type Transaction,
|
|
37
38
|
type TypedData,
|
|
39
|
+
type BuildBatchedUserOpRequest,
|
|
40
|
+
type BuildBatchedUserOpResponse,
|
|
41
|
+
type BroadcastBatchedUserOpRequest,
|
|
42
|
+
type BroadcastBatchedUserOpResponse,
|
|
43
|
+
type UserOperationCall,
|
|
44
|
+
type SendBatchUserOpRequest,
|
|
45
|
+
type SendBatchUserOpTransaction,
|
|
46
|
+
type SendBatchedAssetsRequest,
|
|
38
47
|
} from './shared/types'
|
|
39
48
|
|
|
40
49
|
import {
|
|
@@ -1285,6 +1294,442 @@ class Portal {
|
|
|
1285
1294
|
return this.mpc?.buildTransaction(chainId, to, token, amount, traceId)
|
|
1286
1295
|
}
|
|
1287
1296
|
|
|
1297
|
+
/*******************************
|
|
1298
|
+
* Account Abstraction Methods
|
|
1299
|
+
*******************************/
|
|
1300
|
+
|
|
1301
|
+
public async buildBatchedUserOp(
|
|
1302
|
+
data: BuildBatchedUserOpRequest,
|
|
1303
|
+
): Promise<BuildBatchedUserOpResponse> {
|
|
1304
|
+
return this.mpc?.accountAbstractionBuildBatchedUserOp(data)
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
public async broadcastBatchedUserOp(
|
|
1308
|
+
data: BroadcastBatchedUserOpRequest,
|
|
1309
|
+
): Promise<BroadcastBatchedUserOpResponse> {
|
|
1310
|
+
return this.mpc?.accountAbstractionBroadcastBatchedUserOp(data)
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
/**
|
|
1314
|
+
* Build, sign, and broadcast a batch of EIP-155 UserOperations (ERC-4337).
|
|
1315
|
+
*
|
|
1316
|
+
* Returns the broadcast response containing `userOpHash`. Broadcasting does not
|
|
1317
|
+
* guarantee on-chain inclusion — UserOps are processed by a bundler that may
|
|
1318
|
+
* delay or drop them. To wait for confirmation call:
|
|
1319
|
+
* `await portal.waitForConfirmation(result.data.userOpHash, data.chain)`
|
|
1320
|
+
*
|
|
1321
|
+
* @param data.chain - CAIP-2 chain ID; must start with 'eip155:'
|
|
1322
|
+
* @param data.transactions - Ordered list of transfers to batch
|
|
1323
|
+
* @param data.signatureApprovalMemo - Optional memo for the signing approval prompt
|
|
1324
|
+
* @param data.traceId - Optional trace ID for request correlation
|
|
1325
|
+
*/
|
|
1326
|
+
public async sendBatchUserOp(
|
|
1327
|
+
data: SendBatchUserOpRequest,
|
|
1328
|
+
): Promise<BroadcastBatchedUserOpResponse> {
|
|
1329
|
+
if (!data.chain.startsWith('eip155:')) {
|
|
1330
|
+
throw new Error(
|
|
1331
|
+
'[Portal.sendBatchUserOp] UserOperations are only supported on EIP-155 (EVM) chains',
|
|
1332
|
+
)
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
if (!data.transactions || data.transactions.length === 0) {
|
|
1336
|
+
throw new Error(
|
|
1337
|
+
'[Portal.sendBatchUserOp] transactions must contain at least one transaction',
|
|
1338
|
+
)
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
// Validate every destination address before starting any async work.
|
|
1342
|
+
for (const tx of data.transactions) {
|
|
1343
|
+
if (!tx.to || !/^0x[0-9a-fA-F]{40}$/i.test(tx.to)) {
|
|
1344
|
+
throw new Error(
|
|
1345
|
+
`[Portal.sendBatchUserOp] Invalid 'to' address "${String(tx.to)}": must be a 42-character EVM address (0x + 40 hex chars)`,
|
|
1346
|
+
)
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
const traceId = data.traceId ?? generateTraceId()
|
|
1351
|
+
|
|
1352
|
+
|
|
1353
|
+
// Build each transaction sequentially. The iframe serializes all postMessages
|
|
1354
|
+
// via _portalMessageQueue, so Promise.all would only create the illusion of
|
|
1355
|
+
// parallelism while hiding failures from all-but-the-first transaction.
|
|
1356
|
+
const calls: UserOperationCall[] = []
|
|
1357
|
+
for (let index = 0; index < data.transactions.length; index++) {
|
|
1358
|
+
calls.push(
|
|
1359
|
+
await this.buildUserOpCall(
|
|
1360
|
+
data.chain,
|
|
1361
|
+
data.transactions[index],
|
|
1362
|
+
traceId,
|
|
1363
|
+
index,
|
|
1364
|
+
'Portal.sendBatchUserOp',
|
|
1365
|
+
),
|
|
1366
|
+
)
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
// Build the batched UserOperation.
|
|
1370
|
+
let buildResponse: BuildBatchedUserOpResponse
|
|
1371
|
+
try {
|
|
1372
|
+
buildResponse = await this.mpc?.accountAbstractionBuildBatchedUserOp(
|
|
1373
|
+
{ chain: data.chain, calls },
|
|
1374
|
+
traceId,
|
|
1375
|
+
)
|
|
1376
|
+
} catch (error) {
|
|
1377
|
+
throw new Error(
|
|
1378
|
+
`[Portal.sendBatchUserOp] Failed to build UserOperation: ${error instanceof Error ? error.message : String(error)}`,
|
|
1379
|
+
)
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
// Validate, sign, and broadcast the built UserOperation, reusing the same
|
|
1383
|
+
// traceId for end-to-end correlation.
|
|
1384
|
+
return this.signAndBroadcastBuiltUserOp(
|
|
1385
|
+
buildResponse,
|
|
1386
|
+
data.chain,
|
|
1387
|
+
data.signatureApprovalMemo,
|
|
1388
|
+
traceId,
|
|
1389
|
+
'Portal.sendBatchUserOp',
|
|
1390
|
+
)
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
/**
|
|
1394
|
+
* Build, sign, and broadcast a gas-subsidized batch where the end user
|
|
1395
|
+
* reimburses the platform — in a fee token (e.g. USDC) — for the gas Portal's
|
|
1396
|
+
* paymaster sponsored.
|
|
1397
|
+
*
|
|
1398
|
+
* The flow is two-pass because the reimbursement amount depends on the gas of
|
|
1399
|
+
* the batch it's part of (a chicken-and-egg the helper resolves for you):
|
|
1400
|
+
* 1. build `[...userCalls, feeCallPlaceholder]` to read the estimated gas cost
|
|
1401
|
+
* (`metadata.estimatedGasCostWei`; the placeholder ensures the estimate
|
|
1402
|
+
* reflects the FINAL batch shape)
|
|
1403
|
+
* 2. convert that native gas cost → fee-token amount via your `convertGasToFeeAmount`
|
|
1404
|
+
* 3. build `[...userCalls, feeCall]` with the real amount, sign, broadcast
|
|
1405
|
+
*
|
|
1406
|
+
* Important characteristics:
|
|
1407
|
+
* - You are charging the build-time gas estimate (an upper bound), not the
|
|
1408
|
+
* post-execution actual — actual gas is unknowable before the op runs, and an
|
|
1409
|
+
* atomic batch must fix the reimbursement amount at sign time. Use `bufferBps`
|
|
1410
|
+
* to absorb gas-price drift between the two builds.
|
|
1411
|
+
* - Conversion (native → fee token) is entirely yours; Portal does no FX.
|
|
1412
|
+
* - Throws if the estimated gas cost is 0. Some chains/providers carry no
|
|
1413
|
+
* on-chain fee on the user operation (e.g. Ultra Relay bundler-level
|
|
1414
|
+
* sponsorship on Monad mainnet), so there's nothing to reimburse from; those
|
|
1415
|
+
* chains need a gas price sourced another way.
|
|
1416
|
+
*
|
|
1417
|
+
* @param data.chain - CAIP-2 chain ID; must start with 'eip155:'
|
|
1418
|
+
* @param data.transactions - The end user's actual transfers to execute
|
|
1419
|
+
* @param data.gasReimbursement - Fee token, recipient, and conversion callback
|
|
1420
|
+
* @param data.signatureApprovalMemo - Optional memo for the signing approval prompt
|
|
1421
|
+
* @param data.traceId - Optional trace ID for request correlation
|
|
1422
|
+
*/
|
|
1423
|
+
public async sendBatchedAssets(
|
|
1424
|
+
data: SendBatchedAssetsRequest,
|
|
1425
|
+
): Promise<BroadcastBatchedUserOpResponse> {
|
|
1426
|
+
const ctx = 'Portal.sendBatchedAssets'
|
|
1427
|
+
|
|
1428
|
+
if (!data.chain.startsWith('eip155:')) {
|
|
1429
|
+
throw new Error(
|
|
1430
|
+
`[${ctx}] UserOperations are only supported on EIP-155 (EVM) chains`,
|
|
1431
|
+
)
|
|
1432
|
+
}
|
|
1433
|
+
if (!data.transactions || data.transactions.length === 0) {
|
|
1434
|
+
throw new Error(
|
|
1435
|
+
`[${ctx}] transactions must contain at least one transaction`,
|
|
1436
|
+
)
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
const gr = data.gasReimbursement
|
|
1440
|
+
if (!gr || typeof gr.convertGasToFeeAmount !== 'function') {
|
|
1441
|
+
throw new Error(
|
|
1442
|
+
`[${ctx}] gasReimbursement.convertGasToFeeAmount (a function) is required`,
|
|
1443
|
+
)
|
|
1444
|
+
}
|
|
1445
|
+
if (!gr.feeToken) {
|
|
1446
|
+
throw new Error(`[${ctx}] gasReimbursement.feeToken is required`)
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
// Validate every destination address before starting any async work.
|
|
1450
|
+
const evmAddress = /^0x[0-9a-fA-F]{40}$/i
|
|
1451
|
+
for (const tx of data.transactions) {
|
|
1452
|
+
if (!tx.to || !evmAddress.test(tx.to)) {
|
|
1453
|
+
throw new Error(
|
|
1454
|
+
`[${ctx}] Invalid 'to' address "${String(tx.to)}": must be a 42-character EVM address (0x + 40 hex chars)`,
|
|
1455
|
+
)
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
if (!gr.feeRecipient || !evmAddress.test(gr.feeRecipient)) {
|
|
1459
|
+
throw new Error(
|
|
1460
|
+
`[${ctx}] Invalid gasReimbursement.feeRecipient "${String(gr.feeRecipient)}": must be a 42-character EVM address (0x + 40 hex chars)`,
|
|
1461
|
+
)
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
const traceId = data.traceId ?? generateTraceId()
|
|
1465
|
+
|
|
1466
|
+
// 1. Build the user's actual calls once — they don't change between passes.
|
|
1467
|
+
const userCalls: UserOperationCall[] = []
|
|
1468
|
+
for (let index = 0; index < data.transactions.length; index++) {
|
|
1469
|
+
userCalls.push(
|
|
1470
|
+
await this.buildUserOpCall(
|
|
1471
|
+
data.chain,
|
|
1472
|
+
data.transactions[index],
|
|
1473
|
+
traceId,
|
|
1474
|
+
index,
|
|
1475
|
+
ctx,
|
|
1476
|
+
),
|
|
1477
|
+
)
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
// 2. Build a placeholder fee call so the estimation pass reflects the final
|
|
1481
|
+
// batch shape (an N+1-call executeBatch). The amount does not affect the
|
|
1482
|
+
// gas estimate; only the call's presence and shape do.
|
|
1483
|
+
const feeIndex = data.transactions.length
|
|
1484
|
+
const placeholderAmount = gr.placeholderAmount ?? '0.01'
|
|
1485
|
+
let placeholderFeeCall: UserOperationCall
|
|
1486
|
+
try {
|
|
1487
|
+
placeholderFeeCall = await this.buildUserOpCall(
|
|
1488
|
+
data.chain,
|
|
1489
|
+
{ token: gr.feeToken, value: placeholderAmount, to: gr.feeRecipient },
|
|
1490
|
+
traceId,
|
|
1491
|
+
feeIndex,
|
|
1492
|
+
ctx,
|
|
1493
|
+
)
|
|
1494
|
+
} catch (error) {
|
|
1495
|
+
throw new Error(
|
|
1496
|
+
`[${ctx}] Failed to build placeholder fee call: ${error instanceof Error ? error.message : String(error)}`,
|
|
1497
|
+
)
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
// 3. Estimation pass — build the full batch to read the gas it's bounded by.
|
|
1501
|
+
let estimateResponse: BuildBatchedUserOpResponse
|
|
1502
|
+
try {
|
|
1503
|
+
estimateResponse = await this.mpc?.accountAbstractionBuildBatchedUserOp(
|
|
1504
|
+
{ chain: data.chain, calls: [...userCalls, placeholderFeeCall] },
|
|
1505
|
+
traceId,
|
|
1506
|
+
)
|
|
1507
|
+
} catch (error) {
|
|
1508
|
+
throw new Error(
|
|
1509
|
+
`[${ctx}] Failed to estimate UserOperation gas: ${error instanceof Error ? error.message : String(error)}`,
|
|
1510
|
+
)
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
let gasCostWei = this.resolveUserOpGasCostWei(estimateResponse, ctx)
|
|
1514
|
+
|
|
1515
|
+
// Guard against a zero gas cost. Some chains/providers return no on-chain fee
|
|
1516
|
+
// on the user operation (e.g. Ultra Relay bundler-level sponsorship on Monad
|
|
1517
|
+
// mainnet), so the build-time cost is 0 — there's nothing to derive a
|
|
1518
|
+
// reimbursement from. Fail loudly rather than silently charging the user 0.
|
|
1519
|
+
if (gasCostWei === BigInt(0)) {
|
|
1520
|
+
throw new Error(
|
|
1521
|
+
`[${ctx}] Estimated gas cost is 0 — this chain/provider carries no on-chain fee on the user operation (e.g. Ultra Relay bundler-level sponsorship). Cannot derive a reimbursement amount; supply the gas price another way for this chain.`,
|
|
1522
|
+
)
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
// Apply optional buffer (basis points) to absorb gas-price drift before FX.
|
|
1526
|
+
if (gr.bufferBps && gr.bufferBps > 0) {
|
|
1527
|
+
gasCostWei =
|
|
1528
|
+
(gasCostWei * BigInt(10000 + Math.floor(gr.bufferBps))) / BigInt(10000)
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
// 4. Platform-owned conversion: native gas cost (wei) → fee-token amount.
|
|
1532
|
+
let feeAmount: string
|
|
1533
|
+
try {
|
|
1534
|
+
feeAmount = await gr.convertGasToFeeAmount(gasCostWei)
|
|
1535
|
+
} catch (error) {
|
|
1536
|
+
throw new Error(
|
|
1537
|
+
`[${ctx}] gasReimbursement.convertGasToFeeAmount threw: ${error instanceof Error ? error.message : String(error)}`,
|
|
1538
|
+
)
|
|
1539
|
+
}
|
|
1540
|
+
if (typeof feeAmount !== 'string' || feeAmount.length === 0) {
|
|
1541
|
+
throw new Error(
|
|
1542
|
+
`[${ctx}] convertGasToFeeAmount must return a non-empty amount string, got "${String(feeAmount)}"`,
|
|
1543
|
+
)
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
// 5. Build the real fee call with the converted amount.
|
|
1547
|
+
let feeCall: UserOperationCall
|
|
1548
|
+
try {
|
|
1549
|
+
feeCall = await this.buildUserOpCall(
|
|
1550
|
+
data.chain,
|
|
1551
|
+
{ token: gr.feeToken, value: feeAmount, to: gr.feeRecipient },
|
|
1552
|
+
traceId,
|
|
1553
|
+
feeIndex,
|
|
1554
|
+
ctx,
|
|
1555
|
+
)
|
|
1556
|
+
} catch (error) {
|
|
1557
|
+
throw new Error(
|
|
1558
|
+
`[${ctx}] Failed to build fee reimbursement call: ${error instanceof Error ? error.message : String(error)}`,
|
|
1559
|
+
)
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
// 6. Final pass — build the batch we'll actually sign and broadcast.
|
|
1563
|
+
let buildResponse: BuildBatchedUserOpResponse
|
|
1564
|
+
try {
|
|
1565
|
+
buildResponse = await this.mpc?.accountAbstractionBuildBatchedUserOp(
|
|
1566
|
+
{ chain: data.chain, calls: [...userCalls, feeCall] },
|
|
1567
|
+
traceId,
|
|
1568
|
+
)
|
|
1569
|
+
} catch (error) {
|
|
1570
|
+
throw new Error(
|
|
1571
|
+
`[${ctx}] Failed to build UserOperation: ${error instanceof Error ? error.message : String(error)}`,
|
|
1572
|
+
)
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
return this.signAndBroadcastBuiltUserOp(
|
|
1576
|
+
buildResponse,
|
|
1577
|
+
data.chain,
|
|
1578
|
+
data.signatureApprovalMemo,
|
|
1579
|
+
traceId,
|
|
1580
|
+
ctx,
|
|
1581
|
+
)
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
/**
|
|
1585
|
+
* Build a single ERC-4337 call from a high-level transfer descriptor by routing
|
|
1586
|
+
* it through `buildTransaction` (which resolves token contract + calldata and
|
|
1587
|
+
* normalizes the amount). Native transfers carry `value` (the base-unit amount);
|
|
1588
|
+
* ERC-20 transfers carry `data` and omit `value`.
|
|
1589
|
+
*/
|
|
1590
|
+
private async buildUserOpCall(
|
|
1591
|
+
chain: string,
|
|
1592
|
+
tx: SendBatchUserOpTransaction,
|
|
1593
|
+
traceId: string,
|
|
1594
|
+
index: number,
|
|
1595
|
+
ctx: string,
|
|
1596
|
+
): Promise<UserOperationCall> {
|
|
1597
|
+
let builtTx: BuiltTransaction
|
|
1598
|
+
try {
|
|
1599
|
+
builtTx = await this.buildTransaction(
|
|
1600
|
+
chain,
|
|
1601
|
+
tx.to,
|
|
1602
|
+
tx.token,
|
|
1603
|
+
tx.value,
|
|
1604
|
+
traceId,
|
|
1605
|
+
)
|
|
1606
|
+
} catch (error) {
|
|
1607
|
+
throw new Error(
|
|
1608
|
+
`[${ctx}] Failed to build call for transaction at index ${index}: ${error instanceof Error ? error.message : String(error)}`,
|
|
1609
|
+
)
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
// Validate the built transaction shape before casting. buildTransaction
|
|
1613
|
+
// returns BuiltTransaction (a union type), so the cast is TypeScript-only and
|
|
1614
|
+
// provides no runtime safety against a changed or unexpected backend response.
|
|
1615
|
+
const rawTx = builtTx as {
|
|
1616
|
+
transaction?: Record<string, unknown>
|
|
1617
|
+
metadata?: Record<string, unknown>
|
|
1618
|
+
}
|
|
1619
|
+
if (typeof rawTx?.transaction?.to !== 'string' || !rawTx.transaction.to) {
|
|
1620
|
+
throw new Error(
|
|
1621
|
+
`[${ctx}] buildTransaction returned unexpected shape for transaction at index ${index}`,
|
|
1622
|
+
)
|
|
1623
|
+
}
|
|
1624
|
+
const eip155Tx = builtTx as BuiltEip155Transaction
|
|
1625
|
+
|
|
1626
|
+
// Normalize the data field — guard against undefined/null/empty string from
|
|
1627
|
+
// the backend. Empty data means no calldata (native transfer). rawAmount is
|
|
1628
|
+
// passed as-is (decimal string e.g. '1000000000000000000'); the AA backend's
|
|
1629
|
+
// build-user-operation endpoint accepts decimal or hex.
|
|
1630
|
+
const txData = eip155Tx.transaction.data || '0x'
|
|
1631
|
+
const isNativeTransfer = txData === '0x'
|
|
1632
|
+
return {
|
|
1633
|
+
to: eip155Tx.transaction.to,
|
|
1634
|
+
data: txData,
|
|
1635
|
+
...(isNativeTransfer ? { value: eip155Tx.metadata.rawAmount } : {}),
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
/**
|
|
1640
|
+
* Resolve the native gas cost (in wei) a built UserOperation is bounded by,
|
|
1641
|
+
* from the backend-computed `metadata.estimatedGasCostWei` (`totalGas *
|
|
1642
|
+
* maxFeePerGas`, an upper bound). Throws if the field is absent — the
|
|
1643
|
+
* connect-api build-user-operation gas-cost change must be deployed.
|
|
1644
|
+
*/
|
|
1645
|
+
private resolveUserOpGasCostWei(
|
|
1646
|
+
buildResponse: BuildBatchedUserOpResponse,
|
|
1647
|
+
ctx: string,
|
|
1648
|
+
): bigint {
|
|
1649
|
+
const wei = buildResponse?.metadata?.estimatedGasCostWei
|
|
1650
|
+
if (wei == null || `${wei}`.length === 0) {
|
|
1651
|
+
throw new Error(
|
|
1652
|
+
`[${ctx}] build response is missing metadata.estimatedGasCostWei; the connect-api build-user-operation gas-cost change must be deployed for the target environment`,
|
|
1653
|
+
)
|
|
1654
|
+
}
|
|
1655
|
+
return this.toBigIntOrThrow(wei, 'metadata.estimatedGasCostWei', ctx)
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
private toBigIntOrThrow(value: unknown, field: string, ctx: string): bigint {
|
|
1659
|
+
try {
|
|
1660
|
+
// BigInt() accepts both decimal ('1000000000') and hex ('0x3b9aca00') strings.
|
|
1661
|
+
return BigInt(value as string | number)
|
|
1662
|
+
} catch {
|
|
1663
|
+
throw new Error(
|
|
1664
|
+
`[${ctx}] Could not parse ${field} as an integer: "${String(value)}"`,
|
|
1665
|
+
)
|
|
1666
|
+
}
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
/**
|
|
1670
|
+
* Validate a built UserOperation, sign its hash with the SECP256K1 raw signer,
|
|
1671
|
+
* and broadcast it. Shared by the batched-UserOp send paths.
|
|
1672
|
+
*/
|
|
1673
|
+
private async signAndBroadcastBuiltUserOp(
|
|
1674
|
+
buildResponse: BuildBatchedUserOpResponse,
|
|
1675
|
+
chain: string,
|
|
1676
|
+
signatureApprovalMemo: string | undefined,
|
|
1677
|
+
traceId: string,
|
|
1678
|
+
ctx: string,
|
|
1679
|
+
): Promise<BroadcastBatchedUserOpResponse> {
|
|
1680
|
+
const { userOperation, userOpHash } = buildResponse.data
|
|
1681
|
+
|
|
1682
|
+
// Validate userOperation is parseable JSON before signing or broadcasting.
|
|
1683
|
+
// A malformed string from the backend would produce an opaque bundler rejection.
|
|
1684
|
+
try {
|
|
1685
|
+
const parsed: unknown = JSON.parse(userOperation)
|
|
1686
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
1687
|
+
throw new Error('parsed value is not a JSON object')
|
|
1688
|
+
}
|
|
1689
|
+
} catch (e) {
|
|
1690
|
+
throw new Error(
|
|
1691
|
+
`[${ctx}] buildBatchedUserOp returned an invalid userOperation: ${e instanceof Error ? e.message : String(e)}`,
|
|
1692
|
+
)
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
// Validate userOpHash is a valid 32-byte hex string before signing.
|
|
1696
|
+
// Signing an empty or malformed hash wastes the signing operation and produces
|
|
1697
|
+
// a signature that no bundler will accept.
|
|
1698
|
+
if (!userOpHash || !/^(0x)?[0-9a-fA-F]{64}$/.test(userOpHash)) {
|
|
1699
|
+
throw new Error(
|
|
1700
|
+
`[${ctx}] Invalid userOpHash received from buildBatchedUserOp: "${String(userOpHash)}"`,
|
|
1701
|
+
)
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
// Sign the userOpHash — strip the 0x prefix before passing to rawSign.
|
|
1705
|
+
let signature: string
|
|
1706
|
+
try {
|
|
1707
|
+
const hashToSign = userOpHash.startsWith('0x')
|
|
1708
|
+
? userOpHash.slice(2)
|
|
1709
|
+
: userOpHash
|
|
1710
|
+
signature = await this.rawSign(PortalCurve.SECP256K1, hashToSign, {
|
|
1711
|
+
signatureApprovalMemo,
|
|
1712
|
+
traceId,
|
|
1713
|
+
})
|
|
1714
|
+
} catch (error) {
|
|
1715
|
+
throw new Error(
|
|
1716
|
+
`[${ctx}] Failed to sign userOpHash: ${error instanceof Error ? error.message : String(error)}`,
|
|
1717
|
+
)
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
// Broadcast the signed UserOperation, reusing the same traceId for end-to-end correlation.
|
|
1721
|
+
try {
|
|
1722
|
+
return await this.mpc?.accountAbstractionBroadcastBatchedUserOp(
|
|
1723
|
+
{ chain, userOperation, signature },
|
|
1724
|
+
traceId,
|
|
1725
|
+
)
|
|
1726
|
+
} catch (error) {
|
|
1727
|
+
throw new Error(
|
|
1728
|
+
`[${ctx}] Failed to broadcast UserOperation: ${error instanceof Error ? error.message : String(error)}`,
|
|
1729
|
+
)
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1288
1733
|
/*******************************
|
|
1289
1734
|
* Swaps Methods
|
|
1290
1735
|
*******************************/
|
|
@@ -1551,6 +1996,15 @@ export {
|
|
|
1551
1996
|
type TransactionHistoryItem,
|
|
1552
1997
|
type SolanaTransactionDetails,
|
|
1553
1998
|
type Transaction,
|
|
1999
|
+
type UserOperationCall,
|
|
2000
|
+
type BuildBatchedUserOpRequest,
|
|
2001
|
+
type BuildBatchedUserOpResponse,
|
|
2002
|
+
type BroadcastBatchedUserOpRequest,
|
|
2003
|
+
type BroadcastBatchedUserOpResponse,
|
|
2004
|
+
type SendBatchUserOpTransaction,
|
|
2005
|
+
type SendBatchUserOpRequest,
|
|
2006
|
+
type GasReimbursement,
|
|
2007
|
+
type SendBatchedAssetsRequest,
|
|
1554
2008
|
} from './shared/types'
|
|
1555
2009
|
|
|
1556
2010
|
export type {
|
package/src/mpc/index.test.ts
CHANGED
|
@@ -80,6 +80,10 @@ import {
|
|
|
80
80
|
mockBlockaidScanTokensResponse,
|
|
81
81
|
mockBlockaidScanUrlRequest,
|
|
82
82
|
mockBlockaidScanUrlResponse,
|
|
83
|
+
mockBuildBatchedUserOpRequest,
|
|
84
|
+
mockBuildBatchedUserOpResponse,
|
|
85
|
+
mockBroadcastBatchedUserOpRequest,
|
|
86
|
+
mockBroadcastBatchedUserOpResponse,
|
|
83
87
|
} from '../__mocks/constants'
|
|
84
88
|
import portalMock from '../__mocks/portal/portal'
|
|
85
89
|
import { PortalMpcError } from './errors'
|
|
@@ -4447,4 +4451,164 @@ describe('Mpc', () => {
|
|
|
4447
4451
|
})
|
|
4448
4452
|
})
|
|
4449
4453
|
})
|
|
4454
|
+
|
|
4455
|
+
describe('accountAbstractionBuildBatchedUserOp', () => {
|
|
4456
|
+
const args = mockBuildBatchedUserOpRequest
|
|
4457
|
+
const res = mockBuildBatchedUserOpResponse
|
|
4458
|
+
|
|
4459
|
+
it('should successfully build a user operation', (done) => {
|
|
4460
|
+
jest
|
|
4461
|
+
.spyOn(mpc.iframe?.contentWindow!, 'postMessage')
|
|
4462
|
+
.mockImplementation((message: any, origin?) => {
|
|
4463
|
+
const { type, data } = message
|
|
4464
|
+
|
|
4465
|
+
expect(type).toEqual('portal:accountAbstraction:buildBatchedUserOp')
|
|
4466
|
+
expect(data).toMatchObject(args)
|
|
4467
|
+
expect(typeof message.traceId).toBe('string')
|
|
4468
|
+
expect(origin).toEqual(mockHostOrigin)
|
|
4469
|
+
|
|
4470
|
+
window.dispatchEvent(
|
|
4471
|
+
new MessageEvent('message', {
|
|
4472
|
+
origin: mockHostOrigin,
|
|
4473
|
+
data: {
|
|
4474
|
+
type: 'portal:accountAbstraction:buildBatchedUserOpResult',
|
|
4475
|
+
data: res,
|
|
4476
|
+
},
|
|
4477
|
+
}),
|
|
4478
|
+
)
|
|
4479
|
+
})
|
|
4480
|
+
|
|
4481
|
+
mpc
|
|
4482
|
+
.accountAbstractionBuildBatchedUserOp(args)
|
|
4483
|
+
.then((data) => {
|
|
4484
|
+
expect(data).toEqual(res)
|
|
4485
|
+
done()
|
|
4486
|
+
})
|
|
4487
|
+
.catch((_) => {
|
|
4488
|
+
expect(0).toEqual(1)
|
|
4489
|
+
done()
|
|
4490
|
+
})
|
|
4491
|
+
})
|
|
4492
|
+
|
|
4493
|
+
it('should error out if the iframe sends an error message', (done) => {
|
|
4494
|
+
jest
|
|
4495
|
+
.spyOn(mpc.iframe?.contentWindow!, 'postMessage')
|
|
4496
|
+
.mockImplementationOnce((message: any, origin?) => {
|
|
4497
|
+
const { type, data } = message
|
|
4498
|
+
|
|
4499
|
+
expect(type).toEqual('portal:accountAbstraction:buildBatchedUserOp')
|
|
4500
|
+
expect(data).toMatchObject(args)
|
|
4501
|
+
expect(typeof message.traceId).toBe('string')
|
|
4502
|
+
expect(origin).toEqual(mockHostOrigin)
|
|
4503
|
+
|
|
4504
|
+
window.dispatchEvent(
|
|
4505
|
+
new MessageEvent('message', {
|
|
4506
|
+
origin: mockHostOrigin,
|
|
4507
|
+
data: {
|
|
4508
|
+
type: 'portal:accountAbstraction:buildBatchedUserOpError',
|
|
4509
|
+
data: {
|
|
4510
|
+
code: 1,
|
|
4511
|
+
message: 'test',
|
|
4512
|
+
},
|
|
4513
|
+
},
|
|
4514
|
+
}),
|
|
4515
|
+
)
|
|
4516
|
+
})
|
|
4517
|
+
|
|
4518
|
+
mpc
|
|
4519
|
+
.accountAbstractionBuildBatchedUserOp(args)
|
|
4520
|
+
.then(() => {
|
|
4521
|
+
expect(0).toEqual(1)
|
|
4522
|
+
done()
|
|
4523
|
+
})
|
|
4524
|
+
.catch((e) => {
|
|
4525
|
+
expect(e).toBeInstanceOf(PortalMpcError)
|
|
4526
|
+
expect(e.message).toEqual('test')
|
|
4527
|
+
expect(e.code).toEqual(1)
|
|
4528
|
+
done()
|
|
4529
|
+
})
|
|
4530
|
+
})
|
|
4531
|
+
})
|
|
4532
|
+
|
|
4533
|
+
describe('accountAbstractionBroadcastBatchedUserOp', () => {
|
|
4534
|
+
const args = mockBroadcastBatchedUserOpRequest
|
|
4535
|
+
const res = mockBroadcastBatchedUserOpResponse
|
|
4536
|
+
|
|
4537
|
+
it('should successfully broadcast a user operation', (done) => {
|
|
4538
|
+
jest
|
|
4539
|
+
.spyOn(mpc.iframe?.contentWindow!, 'postMessage')
|
|
4540
|
+
.mockImplementation((message: any, origin?) => {
|
|
4541
|
+
const { type, data } = message
|
|
4542
|
+
|
|
4543
|
+
expect(type).toEqual(
|
|
4544
|
+
'portal:accountAbstraction:broadcastBatchedUserOp',
|
|
4545
|
+
)
|
|
4546
|
+
expect(data).toMatchObject(args)
|
|
4547
|
+
expect(typeof message.traceId).toBe('string')
|
|
4548
|
+
expect(origin).toEqual(mockHostOrigin)
|
|
4549
|
+
|
|
4550
|
+
window.dispatchEvent(
|
|
4551
|
+
new MessageEvent('message', {
|
|
4552
|
+
origin: mockHostOrigin,
|
|
4553
|
+
data: {
|
|
4554
|
+
type: 'portal:accountAbstraction:broadcastBatchedUserOpResult',
|
|
4555
|
+
data: res,
|
|
4556
|
+
},
|
|
4557
|
+
}),
|
|
4558
|
+
)
|
|
4559
|
+
})
|
|
4560
|
+
|
|
4561
|
+
mpc
|
|
4562
|
+
.accountAbstractionBroadcastBatchedUserOp(args)
|
|
4563
|
+
.then((data) => {
|
|
4564
|
+
expect(data).toEqual(res)
|
|
4565
|
+
done()
|
|
4566
|
+
})
|
|
4567
|
+
.catch((_) => {
|
|
4568
|
+
expect(0).toEqual(1)
|
|
4569
|
+
done()
|
|
4570
|
+
})
|
|
4571
|
+
})
|
|
4572
|
+
|
|
4573
|
+
it('should error out if the iframe sends an error message', (done) => {
|
|
4574
|
+
jest
|
|
4575
|
+
.spyOn(mpc.iframe?.contentWindow!, 'postMessage')
|
|
4576
|
+
.mockImplementationOnce((message: any, origin?) => {
|
|
4577
|
+
const { type, data } = message
|
|
4578
|
+
|
|
4579
|
+
expect(type).toEqual(
|
|
4580
|
+
'portal:accountAbstraction:broadcastBatchedUserOp',
|
|
4581
|
+
)
|
|
4582
|
+
expect(data).toMatchObject(args)
|
|
4583
|
+
expect(typeof message.traceId).toBe('string')
|
|
4584
|
+
expect(origin).toEqual(mockHostOrigin)
|
|
4585
|
+
|
|
4586
|
+
window.dispatchEvent(
|
|
4587
|
+
new MessageEvent('message', {
|
|
4588
|
+
origin: mockHostOrigin,
|
|
4589
|
+
data: {
|
|
4590
|
+
type: 'portal:accountAbstraction:broadcastBatchedUserOpError',
|
|
4591
|
+
data: {
|
|
4592
|
+
code: 1,
|
|
4593
|
+
message: 'test',
|
|
4594
|
+
},
|
|
4595
|
+
},
|
|
4596
|
+
}),
|
|
4597
|
+
)
|
|
4598
|
+
})
|
|
4599
|
+
|
|
4600
|
+
mpc
|
|
4601
|
+
.accountAbstractionBroadcastBatchedUserOp(args)
|
|
4602
|
+
.then(() => {
|
|
4603
|
+
expect(0).toEqual(1)
|
|
4604
|
+
done()
|
|
4605
|
+
})
|
|
4606
|
+
.catch((e) => {
|
|
4607
|
+
expect(e).toBeInstanceOf(PortalMpcError)
|
|
4608
|
+
expect(e.message).toEqual('test')
|
|
4609
|
+
expect(e.code).toEqual(1)
|
|
4610
|
+
done()
|
|
4611
|
+
})
|
|
4612
|
+
})
|
|
4613
|
+
})
|
|
4450
4614
|
})
|