@portal-hq/web 3.16.0 → 3.17.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.
@@ -996,6 +996,300 @@ class Portal {
996
996
  return (_a = this.mpc) === null || _a === void 0 ? void 0 : _a.buildTransaction(chainId, to, token, amount, traceId);
997
997
  });
998
998
  }
999
+ /*******************************
1000
+ * Account Abstraction Methods
1001
+ *******************************/
1002
+ buildBatchedUserOp(data) {
1003
+ var _a;
1004
+ return __awaiter(this, void 0, void 0, function* () {
1005
+ return (_a = this.mpc) === null || _a === void 0 ? void 0 : _a.accountAbstractionBuildBatchedUserOp(data);
1006
+ });
1007
+ }
1008
+ broadcastBatchedUserOp(data) {
1009
+ var _a;
1010
+ return __awaiter(this, void 0, void 0, function* () {
1011
+ return (_a = this.mpc) === null || _a === void 0 ? void 0 : _a.accountAbstractionBroadcastBatchedUserOp(data);
1012
+ });
1013
+ }
1014
+ /**
1015
+ * Build, sign, and broadcast a batch of EIP-155 UserOperations (ERC-4337).
1016
+ *
1017
+ * Returns the broadcast response containing `userOpHash`. Broadcasting does not
1018
+ * guarantee on-chain inclusion — UserOps are processed by a bundler that may
1019
+ * delay or drop them. To wait for confirmation call:
1020
+ * `await portal.waitForConfirmation(result.data.userOpHash, data.chain)`
1021
+ *
1022
+ * @param data.chain - CAIP-2 chain ID; must start with 'eip155:'
1023
+ * @param data.transactions - Ordered list of transfers to batch
1024
+ * @param data.signatureApprovalMemo - Optional memo for the signing approval prompt
1025
+ * @param data.traceId - Optional trace ID for request correlation
1026
+ */
1027
+ sendBatchUserOp(data) {
1028
+ var _a, _b;
1029
+ return __awaiter(this, void 0, void 0, function* () {
1030
+ if (!data.chain.startsWith('eip155:')) {
1031
+ throw new Error('[Portal.sendBatchUserOp] UserOperations are only supported on EIP-155 (EVM) chains');
1032
+ }
1033
+ if (!data.transactions || data.transactions.length === 0) {
1034
+ throw new Error('[Portal.sendBatchUserOp] transactions must contain at least one transaction');
1035
+ }
1036
+ // Validate every destination address before starting any async work.
1037
+ for (const tx of data.transactions) {
1038
+ if (!tx.to || !/^0x[0-9a-fA-F]{40}$/i.test(tx.to)) {
1039
+ throw new Error(`[Portal.sendBatchUserOp] Invalid 'to' address "${String(tx.to)}": must be a 42-character EVM address (0x + 40 hex chars)`);
1040
+ }
1041
+ }
1042
+ const traceId = (_a = data.traceId) !== null && _a !== void 0 ? _a : (0, trace_1.generateTraceId)();
1043
+ // Build each transaction sequentially. The iframe serializes all postMessages
1044
+ // via _portalMessageQueue, so Promise.all would only create the illusion of
1045
+ // parallelism while hiding failures from all-but-the-first transaction.
1046
+ const calls = [];
1047
+ for (let index = 0; index < data.transactions.length; index++) {
1048
+ calls.push(yield this.buildUserOpCall(data.chain, data.transactions[index], traceId, index, 'Portal.sendBatchUserOp'));
1049
+ }
1050
+ // Build the batched UserOperation.
1051
+ let buildResponse;
1052
+ try {
1053
+ buildResponse = yield ((_b = this.mpc) === null || _b === void 0 ? void 0 : _b.accountAbstractionBuildBatchedUserOp({ chain: data.chain, calls }, traceId));
1054
+ }
1055
+ catch (error) {
1056
+ throw new Error(`[Portal.sendBatchUserOp] Failed to build UserOperation: ${error instanceof Error ? error.message : String(error)}`);
1057
+ }
1058
+ // Validate, sign, and broadcast the built UserOperation, reusing the same
1059
+ // traceId for end-to-end correlation.
1060
+ return this.signAndBroadcastBuiltUserOp(buildResponse, data.chain, data.signatureApprovalMemo, traceId, 'Portal.sendBatchUserOp');
1061
+ });
1062
+ }
1063
+ /**
1064
+ * Build, sign, and broadcast a gas-subsidized batch where the end user
1065
+ * reimburses the platform — in a fee token (e.g. USDC) — for the gas Portal's
1066
+ * paymaster sponsored.
1067
+ *
1068
+ * The flow is two-pass because the reimbursement amount depends on the gas of
1069
+ * the batch it's part of (a chicken-and-egg the helper resolves for you):
1070
+ * 1. build `[...userCalls, feeCallPlaceholder]` to read the estimated gas cost
1071
+ * (`metadata.estimatedGasCostWei`; the placeholder ensures the estimate
1072
+ * reflects the FINAL batch shape)
1073
+ * 2. convert that native gas cost → fee-token amount via your `convertGasToFeeAmount`
1074
+ * 3. build `[...userCalls, feeCall]` with the real amount, sign, broadcast
1075
+ *
1076
+ * Important characteristics:
1077
+ * - You are charging the build-time gas estimate (an upper bound), not the
1078
+ * post-execution actual — actual gas is unknowable before the op runs, and an
1079
+ * atomic batch must fix the reimbursement amount at sign time. Use `bufferBps`
1080
+ * to absorb gas-price drift between the two builds.
1081
+ * - Conversion (native → fee token) is entirely yours; Portal does no FX.
1082
+ * - Throws if the estimated gas cost is 0. Some chains/providers carry no
1083
+ * on-chain fee on the user operation (e.g. Ultra Relay bundler-level
1084
+ * sponsorship on Monad mainnet), so there's nothing to reimburse from; those
1085
+ * chains need a gas price sourced another way.
1086
+ *
1087
+ * @param data.chain - CAIP-2 chain ID; must start with 'eip155:'
1088
+ * @param data.transactions - The end user's actual transfers to execute
1089
+ * @param data.gasReimbursement - Fee token, recipient, and conversion callback
1090
+ * @param data.signatureApprovalMemo - Optional memo for the signing approval prompt
1091
+ * @param data.traceId - Optional trace ID for request correlation
1092
+ */
1093
+ sendBatchedAssets(data) {
1094
+ var _a, _b, _c, _d;
1095
+ return __awaiter(this, void 0, void 0, function* () {
1096
+ const ctx = 'Portal.sendBatchedAssets';
1097
+ if (!data.chain.startsWith('eip155:')) {
1098
+ throw new Error(`[${ctx}] UserOperations are only supported on EIP-155 (EVM) chains`);
1099
+ }
1100
+ if (!data.transactions || data.transactions.length === 0) {
1101
+ throw new Error(`[${ctx}] transactions must contain at least one transaction`);
1102
+ }
1103
+ const gr = data.gasReimbursement;
1104
+ if (!gr || typeof gr.convertGasToFeeAmount !== 'function') {
1105
+ throw new Error(`[${ctx}] gasReimbursement.convertGasToFeeAmount (a function) is required`);
1106
+ }
1107
+ if (!gr.feeToken) {
1108
+ throw new Error(`[${ctx}] gasReimbursement.feeToken is required`);
1109
+ }
1110
+ // Validate every destination address before starting any async work.
1111
+ const evmAddress = /^0x[0-9a-fA-F]{40}$/i;
1112
+ for (const tx of data.transactions) {
1113
+ if (!tx.to || !evmAddress.test(tx.to)) {
1114
+ throw new Error(`[${ctx}] Invalid 'to' address "${String(tx.to)}": must be a 42-character EVM address (0x + 40 hex chars)`);
1115
+ }
1116
+ }
1117
+ if (!gr.feeRecipient || !evmAddress.test(gr.feeRecipient)) {
1118
+ throw new Error(`[${ctx}] Invalid gasReimbursement.feeRecipient "${String(gr.feeRecipient)}": must be a 42-character EVM address (0x + 40 hex chars)`);
1119
+ }
1120
+ const traceId = (_a = data.traceId) !== null && _a !== void 0 ? _a : (0, trace_1.generateTraceId)();
1121
+ // 1. Build the user's actual calls once — they don't change between passes.
1122
+ const userCalls = [];
1123
+ for (let index = 0; index < data.transactions.length; index++) {
1124
+ userCalls.push(yield this.buildUserOpCall(data.chain, data.transactions[index], traceId, index, ctx));
1125
+ }
1126
+ // 2. Build a placeholder fee call so the estimation pass reflects the final
1127
+ // batch shape (an N+1-call executeBatch). The amount does not affect the
1128
+ // gas estimate; only the call's presence and shape do.
1129
+ const feeIndex = data.transactions.length;
1130
+ const placeholderAmount = (_b = gr.placeholderAmount) !== null && _b !== void 0 ? _b : '0.01';
1131
+ let placeholderFeeCall;
1132
+ try {
1133
+ placeholderFeeCall = yield this.buildUserOpCall(data.chain, { token: gr.feeToken, value: placeholderAmount, to: gr.feeRecipient }, traceId, feeIndex, ctx);
1134
+ }
1135
+ catch (error) {
1136
+ throw new Error(`[${ctx}] Failed to build placeholder fee call: ${error instanceof Error ? error.message : String(error)}`);
1137
+ }
1138
+ // 3. Estimation pass — build the full batch to read the gas it's bounded by.
1139
+ let estimateResponse;
1140
+ try {
1141
+ estimateResponse = yield ((_c = this.mpc) === null || _c === void 0 ? void 0 : _c.accountAbstractionBuildBatchedUserOp({ chain: data.chain, calls: [...userCalls, placeholderFeeCall] }, traceId));
1142
+ }
1143
+ catch (error) {
1144
+ throw new Error(`[${ctx}] Failed to estimate UserOperation gas: ${error instanceof Error ? error.message : String(error)}`);
1145
+ }
1146
+ let gasCostWei = this.resolveUserOpGasCostWei(estimateResponse, ctx);
1147
+ // Guard against a zero gas cost. Some chains/providers return no on-chain fee
1148
+ // on the user operation (e.g. Ultra Relay bundler-level sponsorship on Monad
1149
+ // mainnet), so the build-time cost is 0 — there's nothing to derive a
1150
+ // reimbursement from. Fail loudly rather than silently charging the user 0.
1151
+ if (gasCostWei === BigInt(0)) {
1152
+ throw new Error(`[${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.`);
1153
+ }
1154
+ // Apply optional buffer (basis points) to absorb gas-price drift before FX.
1155
+ if (gr.bufferBps && gr.bufferBps > 0) {
1156
+ gasCostWei =
1157
+ (gasCostWei * BigInt(10000 + Math.floor(gr.bufferBps))) / BigInt(10000);
1158
+ }
1159
+ // 4. Platform-owned conversion: native gas cost (wei) → fee-token amount.
1160
+ let feeAmount;
1161
+ try {
1162
+ feeAmount = yield gr.convertGasToFeeAmount(gasCostWei);
1163
+ }
1164
+ catch (error) {
1165
+ throw new Error(`[${ctx}] gasReimbursement.convertGasToFeeAmount threw: ${error instanceof Error ? error.message : String(error)}`);
1166
+ }
1167
+ if (typeof feeAmount !== 'string' || feeAmount.length === 0) {
1168
+ throw new Error(`[${ctx}] convertGasToFeeAmount must return a non-empty amount string, got "${String(feeAmount)}"`);
1169
+ }
1170
+ // 5. Build the real fee call with the converted amount.
1171
+ let feeCall;
1172
+ try {
1173
+ feeCall = yield this.buildUserOpCall(data.chain, { token: gr.feeToken, value: feeAmount, to: gr.feeRecipient }, traceId, feeIndex, ctx);
1174
+ }
1175
+ catch (error) {
1176
+ throw new Error(`[${ctx}] Failed to build fee reimbursement call: ${error instanceof Error ? error.message : String(error)}`);
1177
+ }
1178
+ // 6. Final pass — build the batch we'll actually sign and broadcast.
1179
+ let buildResponse;
1180
+ try {
1181
+ buildResponse = yield ((_d = this.mpc) === null || _d === void 0 ? void 0 : _d.accountAbstractionBuildBatchedUserOp({ chain: data.chain, calls: [...userCalls, feeCall] }, traceId));
1182
+ }
1183
+ catch (error) {
1184
+ throw new Error(`[${ctx}] Failed to build UserOperation: ${error instanceof Error ? error.message : String(error)}`);
1185
+ }
1186
+ return this.signAndBroadcastBuiltUserOp(buildResponse, data.chain, data.signatureApprovalMemo, traceId, ctx);
1187
+ });
1188
+ }
1189
+ /**
1190
+ * Build a single ERC-4337 call from a high-level transfer descriptor by routing
1191
+ * it through `buildTransaction` (which resolves token contract + calldata and
1192
+ * normalizes the amount). Native transfers carry `value` (the base-unit amount);
1193
+ * ERC-20 transfers carry `data` and omit `value`.
1194
+ */
1195
+ buildUserOpCall(chain, tx, traceId, index, ctx) {
1196
+ var _a;
1197
+ return __awaiter(this, void 0, void 0, function* () {
1198
+ let builtTx;
1199
+ try {
1200
+ builtTx = yield this.buildTransaction(chain, tx.to, tx.token, tx.value, traceId);
1201
+ }
1202
+ catch (error) {
1203
+ throw new Error(`[${ctx}] Failed to build call for transaction at index ${index}: ${error instanceof Error ? error.message : String(error)}`);
1204
+ }
1205
+ // Validate the built transaction shape before casting. buildTransaction
1206
+ // returns BuiltTransaction (a union type), so the cast is TypeScript-only and
1207
+ // provides no runtime safety against a changed or unexpected backend response.
1208
+ const rawTx = builtTx;
1209
+ if (typeof ((_a = rawTx === null || rawTx === void 0 ? void 0 : rawTx.transaction) === null || _a === void 0 ? void 0 : _a.to) !== 'string' || !rawTx.transaction.to) {
1210
+ throw new Error(`[${ctx}] buildTransaction returned unexpected shape for transaction at index ${index}`);
1211
+ }
1212
+ const eip155Tx = builtTx;
1213
+ // Normalize the data field — guard against undefined/null/empty string from
1214
+ // the backend. Empty data means no calldata (native transfer). rawAmount is
1215
+ // passed as-is (decimal string e.g. '1000000000000000000'); the AA backend's
1216
+ // build-user-operation endpoint accepts decimal or hex.
1217
+ const txData = eip155Tx.transaction.data || '0x';
1218
+ const isNativeTransfer = txData === '0x';
1219
+ return Object.assign({ to: eip155Tx.transaction.to, data: txData }, (isNativeTransfer ? { value: eip155Tx.metadata.rawAmount } : {}));
1220
+ });
1221
+ }
1222
+ /**
1223
+ * Resolve the native gas cost (in wei) a built UserOperation is bounded by,
1224
+ * from the backend-computed `metadata.estimatedGasCostWei` (`totalGas *
1225
+ * maxFeePerGas`, an upper bound). Throws if the field is absent — the
1226
+ * connect-api build-user-operation gas-cost change must be deployed.
1227
+ */
1228
+ resolveUserOpGasCostWei(buildResponse, ctx) {
1229
+ var _a;
1230
+ const wei = (_a = buildResponse === null || buildResponse === void 0 ? void 0 : buildResponse.metadata) === null || _a === void 0 ? void 0 : _a.estimatedGasCostWei;
1231
+ if (wei == null || `${wei}`.length === 0) {
1232
+ throw new Error(`[${ctx}] build response is missing metadata.estimatedGasCostWei; the connect-api build-user-operation gas-cost change must be deployed for the target environment`);
1233
+ }
1234
+ return this.toBigIntOrThrow(wei, 'metadata.estimatedGasCostWei', ctx);
1235
+ }
1236
+ toBigIntOrThrow(value, field, ctx) {
1237
+ try {
1238
+ // BigInt() accepts both decimal ('1000000000') and hex ('0x3b9aca00') strings.
1239
+ return BigInt(value);
1240
+ }
1241
+ catch (_a) {
1242
+ throw new Error(`[${ctx}] Could not parse ${field} as an integer: "${String(value)}"`);
1243
+ }
1244
+ }
1245
+ /**
1246
+ * Validate a built UserOperation, sign its hash with the SECP256K1 raw signer,
1247
+ * and broadcast it. Shared by the batched-UserOp send paths.
1248
+ */
1249
+ signAndBroadcastBuiltUserOp(buildResponse, chain, signatureApprovalMemo, traceId, ctx) {
1250
+ var _a;
1251
+ return __awaiter(this, void 0, void 0, function* () {
1252
+ const { userOperation, userOpHash } = buildResponse.data;
1253
+ // Validate userOperation is parseable JSON before signing or broadcasting.
1254
+ // A malformed string from the backend would produce an opaque bundler rejection.
1255
+ try {
1256
+ const parsed = JSON.parse(userOperation);
1257
+ if (!parsed || typeof parsed !== 'object') {
1258
+ throw new Error('parsed value is not a JSON object');
1259
+ }
1260
+ }
1261
+ catch (e) {
1262
+ throw new Error(`[${ctx}] buildBatchedUserOp returned an invalid userOperation: ${e instanceof Error ? e.message : String(e)}`);
1263
+ }
1264
+ // Validate userOpHash is a valid 32-byte hex string before signing.
1265
+ // Signing an empty or malformed hash wastes the signing operation and produces
1266
+ // a signature that no bundler will accept.
1267
+ if (!userOpHash || !/^(0x)?[0-9a-fA-F]{64}$/.test(userOpHash)) {
1268
+ throw new Error(`[${ctx}] Invalid userOpHash received from buildBatchedUserOp: "${String(userOpHash)}"`);
1269
+ }
1270
+ // Sign the userOpHash — strip the 0x prefix before passing to rawSign.
1271
+ let signature;
1272
+ try {
1273
+ const hashToSign = userOpHash.startsWith('0x')
1274
+ ? userOpHash.slice(2)
1275
+ : userOpHash;
1276
+ signature = yield this.rawSign(PortalCurve.SECP256K1, hashToSign, {
1277
+ signatureApprovalMemo,
1278
+ traceId,
1279
+ });
1280
+ }
1281
+ catch (error) {
1282
+ throw new Error(`[${ctx}] Failed to sign userOpHash: ${error instanceof Error ? error.message : String(error)}`);
1283
+ }
1284
+ // Broadcast the signed UserOperation, reusing the same traceId for end-to-end correlation.
1285
+ try {
1286
+ return yield ((_a = this.mpc) === null || _a === void 0 ? void 0 : _a.accountAbstractionBroadcastBatchedUserOp({ chain, userOperation, signature }, traceId));
1287
+ }
1288
+ catch (error) {
1289
+ throw new Error(`[${ctx}] Failed to broadcast UserOperation: ${error instanceof Error ? error.message : String(error)}`);
1290
+ }
1291
+ });
1292
+ }
999
1293
  /*******************************
1000
1294
  * Swaps Methods
1001
1295
  *******************************/
@@ -747,6 +747,237 @@ describe('Portal', () => {
747
747
  expect(portal.mpc.getSources).toHaveBeenCalledWith('eip155:1', 'test');
748
748
  }));
749
749
  });
750
+ describe('buildBatchedUserOp', () => {
751
+ it('should correctly call mpc.accountAbstractionBuildBatchedUserOp', () => __awaiter(void 0, void 0, void 0, function* () {
752
+ yield portal.buildBatchedUserOp(constants_1.mockBuildBatchedUserOpRequest);
753
+ expect(portal.mpc.accountAbstractionBuildBatchedUserOp).toHaveBeenCalledTimes(1);
754
+ expect(portal.mpc.accountAbstractionBuildBatchedUserOp).toHaveBeenCalledWith(constants_1.mockBuildBatchedUserOpRequest);
755
+ }));
756
+ });
757
+ describe('broadcastBatchedUserOp', () => {
758
+ it('should correctly call mpc.accountAbstractionBroadcastBatchedUserOp', () => __awaiter(void 0, void 0, void 0, function* () {
759
+ yield portal.broadcastBatchedUserOp(constants_1.mockBroadcastBatchedUserOpRequest);
760
+ expect(portal.mpc.accountAbstractionBroadcastBatchedUserOp).toHaveBeenCalledTimes(1);
761
+ expect(portal.mpc.accountAbstractionBroadcastBatchedUserOp).toHaveBeenCalledWith(constants_1.mockBroadcastBatchedUserOpRequest);
762
+ }));
763
+ });
764
+ describe('sendBatchUserOp', () => {
765
+ it('should build, sign, and broadcast a batched UserOperation', () => __awaiter(void 0, void 0, void 0, function* () {
766
+ const result = yield portal.sendBatchUserOp(constants_1.mockSendBatchUserOpRequest);
767
+ expect(result).toEqual(expect.objectContaining({
768
+ data: expect.objectContaining({ userOpHash: expect.any(String) }),
769
+ metadata: expect.objectContaining({ chainId: expect.any(String) }),
770
+ }));
771
+ // buildTransaction called once per transaction descriptor
772
+ expect(portal.mpc.buildTransaction).toHaveBeenCalledTimes(2);
773
+ expect(portal.mpc.buildTransaction).toHaveBeenCalledWith(constants_1.mockSendBatchUserOpRequest.chain, constants_1.mockSendBatchUserOpRequest.transactions[0].to, constants_1.mockSendBatchUserOpRequest.transactions[0].token, constants_1.mockSendBatchUserOpRequest.transactions[0].value, expect.any(String));
774
+ expect(portal.mpc.buildTransaction).toHaveBeenCalledWith(constants_1.mockSendBatchUserOpRequest.chain, constants_1.mockSendBatchUserOpRequest.transactions[1].to, constants_1.mockSendBatchUserOpRequest.transactions[1].token, constants_1.mockSendBatchUserOpRequest.transactions[1].value, expect.any(String));
775
+ // buildBatchedUserOp called with ERC-20 call shape (no value field) and the shared traceId
776
+ expect(portal.mpc.accountAbstractionBuildBatchedUserOp).toHaveBeenCalledTimes(1);
777
+ expect(portal.mpc.accountAbstractionBuildBatchedUserOp).toHaveBeenCalledWith({
778
+ chain: constants_1.mockSendBatchUserOpRequest.chain,
779
+ calls: [
780
+ {
781
+ to: constants_1.mockBuiltEip155Transaction.transaction.to,
782
+ data: constants_1.mockBuiltEip155Transaction.transaction.data,
783
+ },
784
+ {
785
+ to: constants_1.mockBuiltEip155Transaction.transaction.to,
786
+ data: constants_1.mockBuiltEip155Transaction.transaction.data,
787
+ },
788
+ ],
789
+ }, expect.any(String));
790
+ // rawSign called with SECP256K1 and the userOpHash with 0x prefix stripped
791
+ expect(portal.mpc.rawSign).toHaveBeenCalledTimes(1);
792
+ expect(portal.mpc.rawSign).toHaveBeenCalledWith(_1.PortalCurve.SECP256K1, constants_1.mockBuildBatchedUserOpResponse.data.userOpHash.slice(2), { signatureApprovalMemo: undefined, traceId: expect.any(String) });
793
+ // broadcastBatchedUserOp called with the built userOperation and signature, and the shared traceId
794
+ expect(portal.mpc.accountAbstractionBroadcastBatchedUserOp).toHaveBeenCalledTimes(1);
795
+ expect(portal.mpc.accountAbstractionBroadcastBatchedUserOp).toHaveBeenCalledWith({
796
+ chain: constants_1.mockSendBatchUserOpRequest.chain,
797
+ userOperation: constants_1.mockBuildBatchedUserOpResponse.data.userOperation,
798
+ signature: constants_1.mockSignedHash,
799
+ }, expect.any(String));
800
+ }));
801
+ it('should propagate the same traceId to buildBatchedUserOp and broadcastBatchedUserOp', () => __awaiter(void 0, void 0, void 0, function* () {
802
+ yield portal.sendBatchUserOp(constants_1.mockSendBatchUserOpRequest);
803
+ const buildTraceId = portal.mpc.accountAbstractionBuildBatchedUserOp.mock.calls[0][1];
804
+ const broadcastTraceId = portal.mpc.accountAbstractionBroadcastBatchedUserOp.mock.calls[0][1];
805
+ expect(typeof buildTraceId).toBe('string');
806
+ expect(buildTraceId).toEqual(broadcastTraceId);
807
+ }));
808
+ it('should include value in the call for native ETH transfers', () => __awaiter(void 0, void 0, void 0, function* () {
809
+ ;
810
+ portal.mpc.buildTransaction.mockResolvedValueOnce(constants_1.mockBuiltEip155TransactionNative);
811
+ yield portal.sendBatchUserOp({
812
+ chain: 'eip155:1',
813
+ transactions: [{ token: 'ETH', value: '0.1', to: '0x1111111111111111111111111111111111111111' }],
814
+ });
815
+ expect(portal.mpc.accountAbstractionBuildBatchedUserOp).toHaveBeenCalledWith({
816
+ chain: 'eip155:1',
817
+ calls: [
818
+ {
819
+ to: constants_1.mockBuiltEip155TransactionNative.transaction.to,
820
+ data: '0x',
821
+ value: constants_1.mockBuiltEip155TransactionNative.metadata.rawAmount,
822
+ },
823
+ ],
824
+ }, expect.any(String));
825
+ }));
826
+ it('should not include value in the call for ERC-20 transfers', () => __awaiter(void 0, void 0, void 0, function* () {
827
+ yield portal.sendBatchUserOp({
828
+ chain: 'eip155:1',
829
+ transactions: [{ token: 'USDC', value: '1.0', to: '0x1111111111111111111111111111111111111111' }],
830
+ });
831
+ const calls = portal.mpc.accountAbstractionBuildBatchedUserOp.mock.calls[0][0].calls;
832
+ expect(calls[0]).not.toHaveProperty('value');
833
+ }));
834
+ it('should forward signatureApprovalMemo to rawSign', () => __awaiter(void 0, void 0, void 0, function* () {
835
+ yield portal.sendBatchUserOp(Object.assign(Object.assign({}, constants_1.mockSendBatchUserOpRequest), { signatureApprovalMemo: 'approve this batch' }));
836
+ expect(portal.mpc.rawSign).toHaveBeenCalledWith(_1.PortalCurve.SECP256K1, expect.any(String), { signatureApprovalMemo: 'approve this batch', traceId: expect.any(String) });
837
+ }));
838
+ it('should throw if chain is not eip155', () => __awaiter(void 0, void 0, void 0, function* () {
839
+ yield expect(portal.sendBatchUserOp(Object.assign(Object.assign({}, constants_1.mockSendBatchUserOpRequest), { chain: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' }))).rejects.toThrow('[Portal.sendBatchUserOp] UserOperations are only supported on EIP-155 (EVM) chains');
840
+ }));
841
+ it('should throw if transactions array is empty', () => __awaiter(void 0, void 0, void 0, function* () {
842
+ yield expect(portal.sendBatchUserOp(Object.assign(Object.assign({}, constants_1.mockSendBatchUserOpRequest), { transactions: [] }))).rejects.toThrow('[Portal.sendBatchUserOp] transactions must contain at least one transaction');
843
+ }));
844
+ it('should throw with index context if buildTransaction fails for transaction 0', () => __awaiter(void 0, void 0, void 0, function* () {
845
+ ;
846
+ portal.mpc.buildTransaction.mockRejectedValueOnce(new Error('Network error'));
847
+ yield expect(portal.sendBatchUserOp({
848
+ chain: 'eip155:1',
849
+ transactions: [{ token: 'USDC', value: '1.0', to: '0x1111111111111111111111111111111111111111' }],
850
+ })).rejects.toThrow('[Portal.sendBatchUserOp] Failed to build call for transaction at index 0: Network error');
851
+ }));
852
+ it('should throw with index context if buildTransaction fails for transaction 1', () => __awaiter(void 0, void 0, void 0, function* () {
853
+ ;
854
+ portal.mpc.buildTransaction
855
+ .mockResolvedValueOnce(constants_1.mockBuiltEip155Transaction)
856
+ .mockRejectedValueOnce(new Error('Network error'));
857
+ yield expect(portal.sendBatchUserOp(constants_1.mockSendBatchUserOpRequest)).rejects.toThrow('[Portal.sendBatchUserOp] Failed to build call for transaction at index 1: Network error');
858
+ }));
859
+ it('should throw if buildBatchedUserOp fails', () => __awaiter(void 0, void 0, void 0, function* () {
860
+ ;
861
+ portal.mpc.accountAbstractionBuildBatchedUserOp.mockRejectedValueOnce(new Error('UserOp build failed'));
862
+ yield expect(portal.sendBatchUserOp(constants_1.mockSendBatchUserOpRequest)).rejects.toThrow('[Portal.sendBatchUserOp] Failed to build UserOperation: UserOp build failed');
863
+ }));
864
+ it('should throw if rawSign fails', () => __awaiter(void 0, void 0, void 0, function* () {
865
+ ;
866
+ portal.mpc.rawSign.mockRejectedValueOnce(new Error('Signing failed'));
867
+ yield expect(portal.sendBatchUserOp(constants_1.mockSendBatchUserOpRequest)).rejects.toThrow('[Portal.sendBatchUserOp] Failed to sign userOpHash: Signing failed');
868
+ }));
869
+ it('should throw if broadcastBatchedUserOp fails', () => __awaiter(void 0, void 0, void 0, function* () {
870
+ ;
871
+ portal.mpc.accountAbstractionBroadcastBatchedUserOp.mockRejectedValueOnce(new Error('Broadcast failed'));
872
+ yield expect(portal.sendBatchUserOp(constants_1.mockSendBatchUserOpRequest)).rejects.toThrow('[Portal.sendBatchUserOp] Failed to broadcast UserOperation: Broadcast failed');
873
+ }));
874
+ });
875
+ describe('sendBatchedAssets', () => {
876
+ const feeRecipient = '0x2222222222222222222222222222222222222222';
877
+ const baseRequest = {
878
+ chain: 'eip155:1',
879
+ transactions: [
880
+ {
881
+ token: 'USDC',
882
+ value: '1.0',
883
+ to: '0x1111111111111111111111111111111111111111',
884
+ },
885
+ ],
886
+ };
887
+ it('should build twice, convert gas to a fee, sign, and broadcast', () => __awaiter(void 0, void 0, void 0, function* () {
888
+ const convertGasToFeeAmount = jest.fn().mockResolvedValue('0.5');
889
+ const result = yield portal.sendBatchedAssets(Object.assign(Object.assign({}, baseRequest), { gasReimbursement: { feeToken: 'USDC', feeRecipient, convertGasToFeeAmount } }));
890
+ expect(result).toEqual(constants_1.mockBroadcastBatchedUserOpResponse);
891
+ // Estimation pass + final pass = 2 builds.
892
+ expect(portal.mpc.accountAbstractionBuildBatchedUserOp).toHaveBeenCalledTimes(2);
893
+ // buildTransaction: 1 user tx + 1 placeholder fee + 1 real fee = 3.
894
+ expect(portal.mpc.buildTransaction).toHaveBeenCalledTimes(3);
895
+ // Conversion called once with gasCostWei = totalGas(100000) * maxFeePerGas(1e9).
896
+ expect(convertGasToFeeAmount).toHaveBeenCalledTimes(1);
897
+ expect(convertGasToFeeAmount).toHaveBeenCalledWith(BigInt('100000000000000'));
898
+ // Placeholder fee call built with the default '0.01'; real fee call with '0.5'.
899
+ expect(portal.mpc.buildTransaction).toHaveBeenCalledWith('eip155:1', feeRecipient, 'USDC', '0.01', expect.any(String));
900
+ expect(portal.mpc.buildTransaction).toHaveBeenCalledWith('eip155:1', feeRecipient, 'USDC', '0.5', expect.any(String));
901
+ expect(portal.mpc.rawSign).toHaveBeenCalledTimes(1);
902
+ expect(portal.mpc.accountAbstractionBroadcastBatchedUserOp).toHaveBeenCalledTimes(1);
903
+ }));
904
+ it('should apply bufferBps to the gas cost before conversion', () => __awaiter(void 0, void 0, void 0, function* () {
905
+ const convertGasToFeeAmount = jest.fn().mockResolvedValue('0.6');
906
+ yield portal.sendBatchedAssets(Object.assign(Object.assign({}, baseRequest), { gasReimbursement: {
907
+ feeToken: 'USDC',
908
+ feeRecipient,
909
+ convertGasToFeeAmount,
910
+ bufferBps: 1000, // +10%
911
+ } }));
912
+ // 1e14 * 11000 / 10000 = 1.1e14
913
+ expect(convertGasToFeeAmount).toHaveBeenCalledWith(BigInt('110000000000000'));
914
+ }));
915
+ it('should throw if the build response is missing estimatedGasCostWei', () => __awaiter(void 0, void 0, void 0, function* () {
916
+ const noGasCost = Object.assign(Object.assign({}, constants_1.mockBuildBatchedUserOpResponse), { metadata: { chainId: 'eip155:1' } });
917
+ portal.mpc.accountAbstractionBuildBatchedUserOp.mockResolvedValueOnce(noGasCost);
918
+ const convertGasToFeeAmount = jest.fn();
919
+ yield expect(portal.sendBatchedAssets(Object.assign(Object.assign({}, baseRequest), { gasReimbursement: { feeToken: 'USDC', feeRecipient, convertGasToFeeAmount } }))).rejects.toThrow('[Portal.sendBatchedAssets] build response is missing metadata.estimatedGasCostWei');
920
+ // Conversion should never run if we can't determine the gas cost.
921
+ expect(convertGasToFeeAmount).not.toHaveBeenCalled();
922
+ }));
923
+ it('should throw if the estimated gas cost is 0 (e.g. Ultra Relay zero-fee path)', () => __awaiter(void 0, void 0, void 0, function* () {
924
+ const zeroCost = Object.assign(Object.assign({}, constants_1.mockBuildBatchedUserOpResponse), { metadata: {
925
+ chainId: 'eip155:1',
926
+ totalGas: '2000000',
927
+ maxFeePerGas: '0',
928
+ estimatedGasCostWei: '0',
929
+ } });
930
+ portal.mpc.accountAbstractionBuildBatchedUserOp.mockResolvedValueOnce(zeroCost);
931
+ const convertGasToFeeAmount = jest.fn();
932
+ yield expect(portal.sendBatchedAssets(Object.assign(Object.assign({}, baseRequest), { gasReimbursement: { feeToken: 'USDC', feeRecipient, convertGasToFeeAmount } }))).rejects.toThrow('[Portal.sendBatchedAssets] Estimated gas cost is 0');
933
+ expect(convertGasToFeeAmount).not.toHaveBeenCalled();
934
+ }));
935
+ it('should reuse the same traceId across both builds', () => __awaiter(void 0, void 0, void 0, function* () {
936
+ const convertGasToFeeAmount = jest.fn().mockResolvedValue('0.5');
937
+ yield portal.sendBatchedAssets(Object.assign(Object.assign({}, baseRequest), { gasReimbursement: { feeToken: 'USDC', feeRecipient, convertGasToFeeAmount } }));
938
+ const calls = portal.mpc.accountAbstractionBuildBatchedUserOp.mock.calls;
939
+ expect(typeof calls[0][1]).toBe('string');
940
+ expect(calls[0][1]).toEqual(calls[1][1]);
941
+ }));
942
+ it('should throw if chain is not eip155', () => __awaiter(void 0, void 0, void 0, function* () {
943
+ yield expect(portal.sendBatchedAssets({
944
+ chain: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp',
945
+ transactions: baseRequest.transactions,
946
+ gasReimbursement: {
947
+ feeToken: 'USDC',
948
+ feeRecipient,
949
+ convertGasToFeeAmount: () => '1',
950
+ },
951
+ })).rejects.toThrow('[Portal.sendBatchedAssets] UserOperations are only supported on EIP-155 (EVM) chains');
952
+ }));
953
+ it('should throw if transactions is empty', () => __awaiter(void 0, void 0, void 0, function* () {
954
+ yield expect(portal.sendBatchedAssets({
955
+ chain: 'eip155:1',
956
+ transactions: [],
957
+ gasReimbursement: {
958
+ feeToken: 'USDC',
959
+ feeRecipient,
960
+ convertGasToFeeAmount: () => '1',
961
+ },
962
+ })).rejects.toThrow('[Portal.sendBatchedAssets] transactions must contain at least one transaction');
963
+ }));
964
+ it('should throw if convertGasToFeeAmount is missing', () => __awaiter(void 0, void 0, void 0, function* () {
965
+ yield expect(portal.sendBatchedAssets(Object.assign(Object.assign({}, baseRequest), { gasReimbursement: { feeToken: 'USDC', feeRecipient } }))).rejects.toThrow('[Portal.sendBatchedAssets] gasReimbursement.convertGasToFeeAmount (a function) is required');
966
+ }));
967
+ it('should throw if feeRecipient is not a valid EVM address', () => __awaiter(void 0, void 0, void 0, function* () {
968
+ yield expect(portal.sendBatchedAssets(Object.assign(Object.assign({}, baseRequest), { gasReimbursement: {
969
+ feeToken: 'USDC',
970
+ feeRecipient: '0xnope',
971
+ convertGasToFeeAmount: () => '1',
972
+ } }))).rejects.toThrow('[Portal.sendBatchedAssets] Invalid gasReimbursement.feeRecipient');
973
+ }));
974
+ it('should surface an error thrown by the conversion callback', () => __awaiter(void 0, void 0, void 0, function* () {
975
+ const convertGasToFeeAmount = jest
976
+ .fn()
977
+ .mockRejectedValue(new Error('rate unavailable'));
978
+ yield expect(portal.sendBatchedAssets(Object.assign(Object.assign({}, baseRequest), { gasReimbursement: { feeToken: 'USDC', feeRecipient, convertGasToFeeAmount } }))).rejects.toThrow('[Portal.sendBatchedAssets] gasReimbursement.convertGasToFeeAmount threw: rate unavailable');
979
+ }));
980
+ });
750
981
  describe('storedClientBackupShare', () => {
751
982
  it('should correctly call mpc.storedClientBackupShare', () => __awaiter(void 0, void 0, void 0, function* () {
752
983
  yield portal.storedClientBackupShare(true, _1.BackupMethods.password);
@@ -14,7 +14,7 @@ const errors_1 = require("./errors");
14
14
  const logger_1 = require("../logger");
15
15
  const index_1 = require("../index");
16
16
  const trace_1 = require("../shared/trace");
17
- const WEB_SDK_VERSION = '3.16.0';
17
+ const WEB_SDK_VERSION = '3.17.0';
18
18
  class Mpc {
19
19
  get ready() {
20
20
  return this._ready;
@@ -229,6 +229,7 @@ class Mpc {
229
229
  param }, ((options === null || options === void 0 ? void 0 : options.signatureApprovalMemo) !== undefined && {
230
230
  signatureApprovalMemo: options.signatureApprovalMemo,
231
231
  })),
232
+ traceId: options === null || options === void 0 ? void 0 : options.traceId,
232
233
  });
233
234
  });
234
235
  }
@@ -916,6 +917,31 @@ class Mpc {
916
917
  });
917
918
  });
918
919
  }
920
+ /*******************************
921
+ * Account Abstraction Methods
922
+ *******************************/
923
+ accountAbstractionBuildBatchedUserOp(data, traceId) {
924
+ return __awaiter(this, void 0, void 0, function* () {
925
+ return this.handleRequestToIframeAndPost({
926
+ methodMessage: 'portal:accountAbstraction:buildBatchedUserOp',
927
+ errorMessage: 'portal:accountAbstraction:buildBatchedUserOpError',
928
+ resultMessage: 'portal:accountAbstraction:buildBatchedUserOpResult',
929
+ data,
930
+ traceId,
931
+ });
932
+ });
933
+ }
934
+ accountAbstractionBroadcastBatchedUserOp(data, traceId) {
935
+ return __awaiter(this, void 0, void 0, function* () {
936
+ return this.handleRequestToIframeAndPost({
937
+ methodMessage: 'portal:accountAbstraction:broadcastBatchedUserOp',
938
+ errorMessage: 'portal:accountAbstraction:broadcastBatchedUserOpError',
939
+ resultMessage: 'portal:accountAbstraction:broadcastBatchedUserOpResult',
940
+ data,
941
+ traceId,
942
+ });
943
+ });
944
+ }
919
945
  /***************************
920
946
  * Private Methods
921
947
  ***************************/