@ic-pay/icpay-sdk 1.3.95 → 1.4.12
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/dist/index.d.ts +47 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +790 -325
- package/dist/index.js.map +1 -1
- package/dist/protected.d.ts.map +1 -1
- package/dist/protected.js.map +1 -1
- package/dist/types/index.d.ts +48 -2
- package/dist/types/index.d.ts.map +1 -1
- package/dist/x402/builders.d.ts +60 -0
- package/dist/x402/builders.d.ts.map +1 -0
- package/dist/x402/builders.js +210 -0
- package/dist/x402/builders.js.map +1 -0
- package/dist/x402/common.d.ts +23 -0
- package/dist/x402/common.d.ts.map +1 -0
- package/dist/x402/common.js +108 -0
- package/dist/x402/common.js.map +1 -0
- package/dist/x402/encode.d.ts +23 -0
- package/dist/x402/encode.d.ts.map +1 -0
- package/dist/x402/encode.js +71 -0
- package/dist/x402/encode.js.map +1 -0
- package/dist/x402/facilitator.d.ts +88 -0
- package/dist/x402/facilitator.d.ts.map +1 -0
- package/dist/x402/facilitator.js +214 -0
- package/dist/x402/facilitator.js.map +1 -0
- package/dist/x402/fetchWithPayment.d.ts +43 -0
- package/dist/x402/fetchWithPayment.d.ts.map +1 -0
- package/dist/x402/fetchWithPayment.js +117 -0
- package/dist/x402/fetchWithPayment.js.map +1 -0
- package/dist/x402/schemas.d.ts +34 -0
- package/dist/x402/schemas.d.ts.map +1 -0
- package/dist/x402/schemas.js +126 -0
- package/dist/x402/schemas.js.map +1 -0
- package/dist/x402/settle-payment.d.ts +120 -0
- package/dist/x402/settle-payment.d.ts.map +1 -0
- package/dist/x402/settle-payment.js +177 -0
- package/dist/x402/settle-payment.js.map +1 -0
- package/dist/x402/sign.d.ts +13 -0
- package/dist/x402/sign.d.ts.map +1 -0
- package/dist/x402/sign.js +221 -0
- package/dist/x402/sign.js.map +1 -0
- package/dist/x402/types.d.ts +58 -0
- package/dist/x402/types.d.ts.map +1 -0
- package/dist/x402/types.js +3 -0
- package/dist/x402/types.js.map +1 -0
- package/dist/x402/verify-payment.d.ts +70 -0
- package/dist/x402/verify-payment.d.ts.map +1 -0
- package/dist/x402/verify-payment.js +123 -0
- package/dist/x402/verify-payment.js.map +1 -0
- package/package.json +7 -7
package/dist/index.js
CHANGED
|
@@ -15,6 +15,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
|
15
15
|
};
|
|
16
16
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
17
|
exports.IcpayWallet = exports.IcpayError = exports.Icpay = void 0;
|
|
18
|
+
const builders_1 = require("./x402/builders");
|
|
18
19
|
const errors_1 = require("./errors");
|
|
19
20
|
const events_1 = require("./events");
|
|
20
21
|
const wallet_1 = require("./wallet");
|
|
@@ -32,6 +33,7 @@ class Icpay {
|
|
|
32
33
|
this.icpayCanisterId = null;
|
|
33
34
|
this.accountInfoCache = null;
|
|
34
35
|
this.verifiedLedgersCache = { data: null, timestamp: 0 };
|
|
36
|
+
this.chainsCache = { data: null, timestamp: 0 };
|
|
35
37
|
this.icpLedgerCanisterId = 'ryjl3-tyaaa-aaaaa-aaaba-cai';
|
|
36
38
|
this.config = {
|
|
37
39
|
environment: 'production',
|
|
@@ -193,7 +195,9 @@ class Icpay {
|
|
|
193
195
|
id: ledger.id,
|
|
194
196
|
name: ledger.name,
|
|
195
197
|
symbol: ledger.symbol,
|
|
198
|
+
shortcode: ledger.shortcode ?? null,
|
|
196
199
|
canisterId: ledger.canisterId,
|
|
200
|
+
chainId: ledger.chainId,
|
|
197
201
|
decimals: ledger.decimals,
|
|
198
202
|
logoUrl: ledger.logoUrl,
|
|
199
203
|
verified: ledger.verified,
|
|
@@ -219,6 +223,45 @@ class Icpay {
|
|
|
219
223
|
throw err;
|
|
220
224
|
}
|
|
221
225
|
}
|
|
226
|
+
/**
|
|
227
|
+
* Get enabled chains (public method)
|
|
228
|
+
*/
|
|
229
|
+
async getChains() {
|
|
230
|
+
this.emitMethodStart('getChains');
|
|
231
|
+
const now = Date.now();
|
|
232
|
+
const cacheAge = 60 * 60 * 1000; // 60 minutes cache
|
|
233
|
+
if (this.chainsCache.data && (now - this.chainsCache.timestamp) < cacheAge) {
|
|
234
|
+
return this.chainsCache.data;
|
|
235
|
+
}
|
|
236
|
+
try {
|
|
237
|
+
const resp = await this.publicApiClient.get('/sdk/public/chains');
|
|
238
|
+
const chains = resp.map((c) => ({
|
|
239
|
+
id: c.id,
|
|
240
|
+
chainType: c.chainType,
|
|
241
|
+
chainName: c.chainName,
|
|
242
|
+
chainId: c.chainId,
|
|
243
|
+
shortcode: c.shortcode ?? null,
|
|
244
|
+
contractAddress: c.contractAddress ?? null,
|
|
245
|
+
enabled: !!c.enabled,
|
|
246
|
+
rpcUrlPublic: c.rpcUrlPublic ?? null,
|
|
247
|
+
explorerUrl: c.explorerUrl ?? null,
|
|
248
|
+
nativeSymbol: c.nativeSymbol ?? null,
|
|
249
|
+
confirmationsRequired: typeof c.confirmationsRequired === 'number' ? c.confirmationsRequired : parseInt(String(c.confirmationsRequired || 0), 10) || 0,
|
|
250
|
+
}));
|
|
251
|
+
this.chainsCache = { data: chains, timestamp: now };
|
|
252
|
+
this.emitMethodSuccess('getChains', { count: chains.length });
|
|
253
|
+
return chains;
|
|
254
|
+
}
|
|
255
|
+
catch (error) {
|
|
256
|
+
const err = new errors_1.IcpayError({
|
|
257
|
+
code: errors_1.ICPAY_ERROR_CODES.API_ERROR,
|
|
258
|
+
message: 'Failed to fetch chains',
|
|
259
|
+
details: error,
|
|
260
|
+
});
|
|
261
|
+
this.emitMethodError('getChains', err);
|
|
262
|
+
throw err;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
222
265
|
/**
|
|
223
266
|
* Get a verified ledger's canister ID by its symbol (public helper)
|
|
224
267
|
*/
|
|
@@ -292,6 +335,375 @@ class Icpay {
|
|
|
292
335
|
});
|
|
293
336
|
}
|
|
294
337
|
}
|
|
338
|
+
async processPaymentByChain(params) {
|
|
339
|
+
const normalized = (params.chainType || '').toLowerCase();
|
|
340
|
+
switch (normalized) {
|
|
341
|
+
case 'ic':
|
|
342
|
+
case 'icp':
|
|
343
|
+
case 'internet-computer':
|
|
344
|
+
return await this.processICPayment(params);
|
|
345
|
+
case 'evm':
|
|
346
|
+
case 'ethereum':
|
|
347
|
+
return await this.processEvmPayment(params);
|
|
348
|
+
case 'solana':
|
|
349
|
+
throw new errors_1.IcpayError({
|
|
350
|
+
code: errors_1.ICPAY_ERROR_CODES.INVALID_CONFIG,
|
|
351
|
+
message: 'Solana payments are not implemented yet',
|
|
352
|
+
details: { chainType: params.chainType, chainId: params.chainId }
|
|
353
|
+
});
|
|
354
|
+
case 'sui':
|
|
355
|
+
throw new errors_1.IcpayError({
|
|
356
|
+
code: errors_1.ICPAY_ERROR_CODES.INVALID_CONFIG,
|
|
357
|
+
message: 'Sui payments are not implemented yet',
|
|
358
|
+
details: { chainType: params.chainType, chainId: params.chainId }
|
|
359
|
+
});
|
|
360
|
+
case 'onramp':
|
|
361
|
+
return {
|
|
362
|
+
transactionId: 0,
|
|
363
|
+
status: 'pending',
|
|
364
|
+
amount: params.resolvedAmountStr || params.amount.toString(),
|
|
365
|
+
recipientCanister: params.ledgerCanisterId,
|
|
366
|
+
timestamp: new Date(),
|
|
367
|
+
metadata: { paymentIntentId: params.paymentIntentId, onramp: params.onrampData || true },
|
|
368
|
+
};
|
|
369
|
+
default:
|
|
370
|
+
throw new errors_1.IcpayError({
|
|
371
|
+
code: errors_1.ICPAY_ERROR_CODES.INVALID_CONFIG,
|
|
372
|
+
message: 'Unknown or missing chain type for payment processing',
|
|
373
|
+
details: { chainType: params.chainType, chainId: params.chainId }
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
// Public helper: build EVM bytes32 id from accountId and intentCode
|
|
378
|
+
// Layout matches PaymentProcessor.packId(accountId, appId):
|
|
379
|
+
// high 8 bytes = uint64(accountId) big-endian; low 24 bytes = bytes24 with intentCode in the lowest 4 bytes (big-endian)
|
|
380
|
+
packEvmId(accountCanisterId, intentCode) {
|
|
381
|
+
const accountIdNum = BigInt(accountCanisterId);
|
|
382
|
+
const intentCodeNum = BigInt(intentCode);
|
|
383
|
+
const out = new Uint8Array(32);
|
|
384
|
+
// high 8 bytes (big-endian) accountId
|
|
385
|
+
for (let i = 0; i < 8; i++) {
|
|
386
|
+
out[i] = Number((accountIdNum >> BigInt(8 * (7 - i))) & 0xffn);
|
|
387
|
+
}
|
|
388
|
+
// next 20 bytes are zero
|
|
389
|
+
// last 4 bytes = intentCode big-endian
|
|
390
|
+
out[28] = Number((intentCodeNum >> 24n) & 0xffn);
|
|
391
|
+
out[29] = Number((intentCodeNum >> 16n) & 0xffn);
|
|
392
|
+
out[30] = Number((intentCodeNum >> 8n) & 0xffn);
|
|
393
|
+
out[31] = Number(intentCodeNum & 0xffn);
|
|
394
|
+
const bytesToHex = (u) => Array.from(u).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
395
|
+
return bytesToHex(out);
|
|
396
|
+
}
|
|
397
|
+
async processICPayment(params) {
|
|
398
|
+
const { ledgerCanisterId, amount, memo, paymentIntentId, request, metadata } = params;
|
|
399
|
+
// Prefer contractAddress from intent (for IC, this is the canister id)
|
|
400
|
+
let toPrincipal = (params.contractAddress && typeof params.contractAddress === 'string') ? params.contractAddress : undefined;
|
|
401
|
+
const host = this.icHost;
|
|
402
|
+
if (!toPrincipal) {
|
|
403
|
+
if (!this.icpayCanisterId) {
|
|
404
|
+
await this.fetchAccountInfo();
|
|
405
|
+
}
|
|
406
|
+
if (!this.icpayCanisterId) {
|
|
407
|
+
try {
|
|
408
|
+
const acct = await this.getAccountInfo();
|
|
409
|
+
if (acct?.icpayCanisterId) {
|
|
410
|
+
this.icpayCanisterId = acct.icpayCanisterId.toString();
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
catch { }
|
|
414
|
+
}
|
|
415
|
+
if (!this.icpayCanisterId || typeof this.icpayCanisterId !== 'string') {
|
|
416
|
+
const err = new errors_1.IcpayError({
|
|
417
|
+
code: errors_1.ICPAY_ERROR_CODES.INVALID_CONFIG,
|
|
418
|
+
message: 'Could not resolve ICPay canister ID from account info',
|
|
419
|
+
details: { accountInfoCache: this.accountInfoCache }
|
|
420
|
+
});
|
|
421
|
+
this.emitMethodError('createPayment', err);
|
|
422
|
+
throw err;
|
|
423
|
+
}
|
|
424
|
+
toPrincipal = this.icpayCanisterId;
|
|
425
|
+
}
|
|
426
|
+
this.icpayCanisterId = toPrincipal;
|
|
427
|
+
// Ensure Plug has whitelisted the target application canister before initiating transfer
|
|
428
|
+
try {
|
|
429
|
+
const isBrowser = typeof window !== 'undefined';
|
|
430
|
+
const appCanisterId = (typeof toPrincipal === 'string' && toPrincipal.trim().length > 0) ? toPrincipal : null;
|
|
431
|
+
if (isBrowser && appCanisterId && window?.ic?.plug?.requestConnect) {
|
|
432
|
+
await window.ic.plug.requestConnect({ host, whitelist: [appCanisterId] });
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
catch {
|
|
436
|
+
// Non-fatal; continue even if whitelist step fails (wallet may already trust canister)
|
|
437
|
+
}
|
|
438
|
+
let transferResult;
|
|
439
|
+
try {
|
|
440
|
+
// ICP Ledger: use ICRC-1 transfer (ICP ledger supports ICRC-1)
|
|
441
|
+
(0, utils_1.debugLog)(this.config.debug || false, 'sending ICRC-1 transfer (ICP)');
|
|
442
|
+
transferResult = await this.sendFundsToLedger(ledgerCanisterId, toPrincipal, amount, memo, host);
|
|
443
|
+
}
|
|
444
|
+
catch (transferError) {
|
|
445
|
+
// Some wallets/networks return a timeout or transient 5xx even when the transfer was accepted.
|
|
446
|
+
// Treat these as processing and continue with intent notification so users don't double-send.
|
|
447
|
+
const msg = String(transferError?.message || '');
|
|
448
|
+
const lower = msg.toLowerCase();
|
|
449
|
+
const isTimeout = lower.includes('request timed out');
|
|
450
|
+
const isProcessing = isTimeout && lower.includes('processing');
|
|
451
|
+
// DFINITY HTTP agent transient error when subnet has no healthy nodes (e.g., during upgrade)
|
|
452
|
+
const isNoHealthyNodes = lower.includes('no_healthy_nodes') || lower.includes('service unavailable') || lower.includes('503');
|
|
453
|
+
// Plug inpage transport sometimes throws readState errors after a signed call even though the tx went through
|
|
454
|
+
const isPlugReadState = lower.includes('read state request') || lower.includes('readstate') || lower.includes('response could not be found');
|
|
455
|
+
if (isTimeout || isProcessing || isNoHealthyNodes || isPlugReadState) {
|
|
456
|
+
(0, utils_1.debugLog)(this.config.debug || false, 'transfer timed out, proceeding with intent notification', { message: msg });
|
|
457
|
+
// Long-poll the public notify endpoint using only the intent id (no canister tx id available)
|
|
458
|
+
const publicNotify = await this.performNotifyPaymentIntent({
|
|
459
|
+
paymentIntentId: paymentIntentId,
|
|
460
|
+
maxAttempts: 120,
|
|
461
|
+
delayMs: 1000,
|
|
462
|
+
});
|
|
463
|
+
// Derive status from API response
|
|
464
|
+
let statusString = 'pending';
|
|
465
|
+
const apiStatus = publicNotify?.paymentIntent?.status || publicNotify?.payment?.status || publicNotify?.status;
|
|
466
|
+
if (typeof apiStatus === 'string') {
|
|
467
|
+
const norm = apiStatus.toLowerCase();
|
|
468
|
+
if (norm === 'completed' || norm === 'succeeded')
|
|
469
|
+
statusString = 'completed';
|
|
470
|
+
else if (norm === 'failed' || norm === 'canceled' || norm === 'cancelled')
|
|
471
|
+
statusString = 'failed';
|
|
472
|
+
}
|
|
473
|
+
const response = {
|
|
474
|
+
transactionId: 0,
|
|
475
|
+
status: statusString,
|
|
476
|
+
amount: amount.toString(),
|
|
477
|
+
recipientCanister: ledgerCanisterId,
|
|
478
|
+
timestamp: new Date(),
|
|
479
|
+
description: 'Fund transfer',
|
|
480
|
+
metadata: request.metadata,
|
|
481
|
+
payment: publicNotify,
|
|
482
|
+
};
|
|
483
|
+
if (statusString === 'completed') {
|
|
484
|
+
const requested = publicNotify?.payment?.requestedAmount || null;
|
|
485
|
+
const paid = publicNotify?.payment?.paidAmount || null;
|
|
486
|
+
const isMismatched = publicNotify?.payment?.status === 'mismatched';
|
|
487
|
+
if (isMismatched) {
|
|
488
|
+
this.emit('icpay-sdk-transaction-mismatched', { ...response, requestedAmount: requested, paidAmount: paid });
|
|
489
|
+
this.emit('icpay-sdk-transaction-updated', { ...response, status: 'mismatched', requestedAmount: requested, paidAmount: paid });
|
|
490
|
+
}
|
|
491
|
+
else {
|
|
492
|
+
this.emit('icpay-sdk-transaction-completed', response);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
else if (statusString === 'failed') {
|
|
496
|
+
this.emit('icpay-sdk-transaction-failed', response);
|
|
497
|
+
}
|
|
498
|
+
else {
|
|
499
|
+
this.emit('icpay-sdk-transaction-updated', response);
|
|
500
|
+
}
|
|
501
|
+
return response;
|
|
502
|
+
}
|
|
503
|
+
throw transferError;
|
|
504
|
+
}
|
|
505
|
+
// Assume transferResult returns a block index or transaction id
|
|
506
|
+
const blockIndex = transferResult?.Ok?.toString() || transferResult?.blockIndex?.toString() || `temp-${Date.now()}`;
|
|
507
|
+
(0, utils_1.debugLog)(this.config.debug || false, 'transfer result', { blockIndex });
|
|
508
|
+
// First, notify the canister about the ledger transaction (best-effort)
|
|
509
|
+
let canisterTransactionId;
|
|
510
|
+
try {
|
|
511
|
+
(0, utils_1.debugLog)(this.config.debug || false, 'notifying canister about ledger tx', { icpayCanisterId: this.icpayCanisterId, ledgerCanisterId, blockIndex });
|
|
512
|
+
const notifyRes = await this.notifyLedgerTransaction(this.icpayCanisterId, ledgerCanisterId, BigInt(blockIndex));
|
|
513
|
+
if (typeof notifyRes === 'string') {
|
|
514
|
+
const parsed = parseInt(notifyRes, 10);
|
|
515
|
+
canisterTransactionId = Number.isFinite(parsed) ? parsed : undefined;
|
|
516
|
+
}
|
|
517
|
+
else if (notifyRes && typeof notifyRes.id !== 'undefined') {
|
|
518
|
+
const parsed = parseInt(String(notifyRes.id), 10);
|
|
519
|
+
canisterTransactionId = Number.isFinite(parsed) ? parsed : undefined;
|
|
520
|
+
}
|
|
521
|
+
else {
|
|
522
|
+
canisterTransactionId = undefined;
|
|
523
|
+
}
|
|
524
|
+
(0, utils_1.debugLog)(this.config.debug || false, 'canister notified', { canisterTransactionId });
|
|
525
|
+
}
|
|
526
|
+
catch (notifyError) {
|
|
527
|
+
// Do not fall back to ledger block index as canister tx id; let API resolve by intent id
|
|
528
|
+
canisterTransactionId = undefined;
|
|
529
|
+
(0, utils_1.debugLog)(this.config.debug || false, 'notify failed; proceeding without canister tx id', { error: notifyError?.message });
|
|
530
|
+
}
|
|
531
|
+
// Durable wait until API returns terminal status (completed/mismatched/failed/canceled)
|
|
532
|
+
const finalResponse = await this.awaitIntentTerminal({
|
|
533
|
+
paymentIntentId: paymentIntentId,
|
|
534
|
+
canisterTransactionId: (typeof canisterTransactionId === 'number' && Number.isFinite(canisterTransactionId)) ? String(canisterTransactionId) : undefined,
|
|
535
|
+
ledgerCanisterId: ledgerCanisterId,
|
|
536
|
+
amount: amount.toString(),
|
|
537
|
+
metadata: metadata ?? request.metadata,
|
|
538
|
+
});
|
|
539
|
+
return finalResponse;
|
|
540
|
+
}
|
|
541
|
+
async processEvmPayment(params) {
|
|
542
|
+
const contractAddress = params.contractAddress;
|
|
543
|
+
if (!contractAddress) {
|
|
544
|
+
throw new errors_1.IcpayError({
|
|
545
|
+
code: errors_1.ICPAY_ERROR_CODES.INVALID_CONFIG,
|
|
546
|
+
message: 'Missing EVM contract address in payment intent',
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
const eth = this.config?.evmProvider || globalThis?.ethereum || (typeof window !== 'undefined' ? window.ethereum : null);
|
|
550
|
+
if (!eth || !eth.request) {
|
|
551
|
+
throw new errors_1.IcpayError({
|
|
552
|
+
code: errors_1.ICPAY_ERROR_CODES.WALLET_PROVIDER_NOT_AVAILABLE,
|
|
553
|
+
message: 'EVM provider not available (window.ethereum)',
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
// Ensure correct chain if provided
|
|
557
|
+
try {
|
|
558
|
+
const desiredChain = params.rpcChainId ?? null;
|
|
559
|
+
if (desiredChain != null) {
|
|
560
|
+
const currentHex = await eth.request({ method: 'eth_chainId' });
|
|
561
|
+
const currentDec = parseInt(currentHex, 16);
|
|
562
|
+
const desiredDec = typeof desiredChain === 'string' && String(desiredChain).startsWith('0x') ? parseInt(String(desiredChain), 16) : parseInt(String(desiredChain), 10);
|
|
563
|
+
if (Number.isFinite(desiredDec) && currentDec !== desiredDec) {
|
|
564
|
+
const hex = '0x' + desiredDec.toString(16);
|
|
565
|
+
try {
|
|
566
|
+
await eth.request({ method: 'wallet_switchEthereumChain', params: [{ chainId: hex }] });
|
|
567
|
+
}
|
|
568
|
+
catch (e) {
|
|
569
|
+
// best-effort add & switch if we have chainName/rpcUrlPublic
|
|
570
|
+
const rpcUrls = (params.rpcUrlPublic ? [String(params.rpcUrlPublic)] : []).filter(Boolean);
|
|
571
|
+
const chainName = String(params.chainName || `Network ${desiredDec}`);
|
|
572
|
+
if (rpcUrls.length > 0) {
|
|
573
|
+
try {
|
|
574
|
+
await eth.request({
|
|
575
|
+
method: 'wallet_addEthereumChain',
|
|
576
|
+
params: [{ chainId: hex, chainName, rpcUrls }],
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
catch (e2) {
|
|
580
|
+
throw new errors_1.IcpayError({ code: errors_1.ICPAY_ERROR_CODES.INVALID_CONFIG, message: 'Wrong EVM network. Switch wallet to the correct chain.' });
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
else {
|
|
584
|
+
throw new errors_1.IcpayError({ code: errors_1.ICPAY_ERROR_CODES.INVALID_CONFIG, message: 'Wrong EVM network. Switch wallet to the correct chain.' });
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
catch { }
|
|
591
|
+
const tokenAddress = params.ledgerCanisterId || null;
|
|
592
|
+
const isNative = !tokenAddress || /^0x0{40}$/i.test(String(tokenAddress));
|
|
593
|
+
const amountHex = '0x' + params.amount.toString(16);
|
|
594
|
+
// ABI encoding helpers
|
|
595
|
+
const toUint64 = (n) => n.toString(16).padStart(64, '0');
|
|
596
|
+
const toAddressPadded = (addr) => addr.replace(/^0x/i, '').padStart(64, '0');
|
|
597
|
+
const toUint256 = (n) => n.toString(16).padStart(64, '0');
|
|
598
|
+
// Prefer selectors from API; otherwise fallback to constants provided by backend
|
|
599
|
+
const apiSelectors = params.request.__functionSelectors || {};
|
|
600
|
+
const selector = {
|
|
601
|
+
payNative: apiSelectors.payNative || '0x320ca36d',
|
|
602
|
+
payERC20: apiSelectors.payERC20 || '0x1d8be466',
|
|
603
|
+
};
|
|
604
|
+
// Build EVM id bytes32 using shared helper
|
|
605
|
+
const accountIdNum = BigInt(params.accountCanisterId || 0);
|
|
606
|
+
const idHex = this.packEvmId(String(accountIdNum), Number(params.paymentIntentCode ?? 0));
|
|
607
|
+
// Debug: summarize EVM call parameters
|
|
608
|
+
try {
|
|
609
|
+
(0, utils_1.debugLog)(this.config.debug || false, 'evm params', {
|
|
610
|
+
chainId: params.chainId,
|
|
611
|
+
contractAddress,
|
|
612
|
+
tokenAddress,
|
|
613
|
+
isNative,
|
|
614
|
+
amount: params.amount?.toString?.(),
|
|
615
|
+
amountHex,
|
|
616
|
+
accountCanisterId: params.accountCanisterId,
|
|
617
|
+
memoLen: params.memo?.length,
|
|
618
|
+
idHexLen: idHex?.length,
|
|
619
|
+
selectorPayNative: selector.payNative,
|
|
620
|
+
selectorPayERC20: selector.payERC20,
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
catch { }
|
|
624
|
+
// Helper to poll receipt until mined or timeout
|
|
625
|
+
const waitForReceipt = async (hash, attempts = 60, delayMs = 1000) => {
|
|
626
|
+
for (let i = 0; i < attempts; i++) {
|
|
627
|
+
try {
|
|
628
|
+
const receipt = await eth.request({ method: 'eth_getTransactionReceipt', params: [hash] });
|
|
629
|
+
if (receipt && receipt.blockNumber)
|
|
630
|
+
return receipt;
|
|
631
|
+
}
|
|
632
|
+
catch { }
|
|
633
|
+
await new Promise(r => setTimeout(r, delayMs));
|
|
634
|
+
}
|
|
635
|
+
return null;
|
|
636
|
+
};
|
|
637
|
+
let txHash;
|
|
638
|
+
try {
|
|
639
|
+
// Resolve owner (from address) once for all EVM calls
|
|
640
|
+
const accounts = await eth.request({ method: 'eth_requestAccounts' });
|
|
641
|
+
const lowerAccounts = Array.isArray(accounts) ? accounts.map((a) => String(a).toLowerCase()) : [];
|
|
642
|
+
const provided = (params.request?.expectedSenderPrincipal || '').toString().toLowerCase();
|
|
643
|
+
let owner = '';
|
|
644
|
+
if (provided && lowerAccounts.includes(provided)) {
|
|
645
|
+
owner = accounts[lowerAccounts.indexOf(provided)] || '';
|
|
646
|
+
}
|
|
647
|
+
else {
|
|
648
|
+
owner = (accounts && accounts[0]) || '';
|
|
649
|
+
}
|
|
650
|
+
if (!owner)
|
|
651
|
+
throw new errors_1.IcpayError({ code: errors_1.ICPAY_ERROR_CODES.WALLET_NOT_CONNECTED, message: 'EVM wallet not connected' });
|
|
652
|
+
(0, utils_1.debugLog)(this.config.debug || false, 'evm from account', { owner });
|
|
653
|
+
if (isNative) {
|
|
654
|
+
const data = selector.payNative + idHex + toUint64(accountIdNum);
|
|
655
|
+
(0, utils_1.debugLog)(this.config.debug || false, 'evm native tx', { to: contractAddress, from: owner, dataLen: data.length, value: amountHex });
|
|
656
|
+
txHash = await eth.request({ method: 'eth_sendTransaction', params: [{ from: owner, to: contractAddress, data, value: amountHex }] });
|
|
657
|
+
}
|
|
658
|
+
else {
|
|
659
|
+
// Ensure allowance(owner -> spender=contractAddress)
|
|
660
|
+
const allowanceSelector = '0xdd62ed3e'; // allowance(address,address)
|
|
661
|
+
const approveSelector = '0x095ea7b3'; // approve(address,uint256)
|
|
662
|
+
const allowanceData = allowanceSelector + toAddressPadded(owner) + toAddressPadded(contractAddress);
|
|
663
|
+
(0, utils_1.debugLog)(this.config.debug || false, 'evm erc20 allowance', { owner, spender: contractAddress, token: tokenAddress, data: allowanceData });
|
|
664
|
+
let allowanceHex = await eth.request({ method: 'eth_call', params: [{ to: String(tokenAddress), data: allowanceData }, 'latest'] });
|
|
665
|
+
if (typeof allowanceHex === 'string' && allowanceHex.startsWith('0x')) {
|
|
666
|
+
const allowance = BigInt(allowanceHex);
|
|
667
|
+
(0, utils_1.debugLog)(this.config.debug || false, 'evm erc20 allowance result', { allowance: allowance.toString() });
|
|
668
|
+
if (allowance < params.amount) {
|
|
669
|
+
const approveData = approveSelector + toAddressPadded(contractAddress) + toUint256(params.amount);
|
|
670
|
+
(0, utils_1.debugLog)(this.config.debug || false, 'evm erc20 approve', { to: tokenAddress, from: owner, dataLen: approveData.length, amount: params.amount.toString() });
|
|
671
|
+
const approveTx = await eth.request({ method: 'eth_sendTransaction', params: [{ from: owner, to: String(tokenAddress), data: approveData }] });
|
|
672
|
+
(0, utils_1.debugLog)(this.config.debug || false, 'evm erc20 approve sent', { tx: approveTx });
|
|
673
|
+
await waitForReceipt(approveTx, 90, 1000);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
const data = selector.payERC20 + idHex + toUint64(accountIdNum) + toAddressPadded(String(tokenAddress)) + toUint256(params.amount);
|
|
677
|
+
(0, utils_1.debugLog)(this.config.debug || false, 'evm erc20 pay', { to: contractAddress, from: owner, token: tokenAddress, dataLen: data.length });
|
|
678
|
+
txHash = await eth.request({ method: 'eth_sendTransaction', params: [{ from: owner, to: contractAddress, data }] });
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
catch (e) {
|
|
682
|
+
try {
|
|
683
|
+
(0, utils_1.debugLog)(this.config.debug || false, 'evm tx error', { message: e?.message, code: e?.code, data: e?.data, error: e });
|
|
684
|
+
}
|
|
685
|
+
catch { }
|
|
686
|
+
throw new errors_1.IcpayError({ code: errors_1.ICPAY_ERROR_CODES.TRANSACTION_FAILED, message: 'EVM transaction failed', details: e });
|
|
687
|
+
}
|
|
688
|
+
// Notify API with tx hash and wait for terminal status
|
|
689
|
+
try {
|
|
690
|
+
this.emitMethodSuccess('notifyLedgerTransaction', { paymentIntentId: params.paymentIntentId });
|
|
691
|
+
}
|
|
692
|
+
catch { }
|
|
693
|
+
// Inform API immediately with tx hash so it can start indexing
|
|
694
|
+
try {
|
|
695
|
+
await this.performNotifyPaymentIntent({ paymentIntentId: params.paymentIntentId, transactionId: txHash, maxAttempts: 1 });
|
|
696
|
+
}
|
|
697
|
+
catch { }
|
|
698
|
+
const finalResponse = await this.awaitIntentTerminal({
|
|
699
|
+
paymentIntentId: params.paymentIntentId,
|
|
700
|
+
transactionId: txHash,
|
|
701
|
+
ledgerCanisterId: params.ledgerCanisterId,
|
|
702
|
+
amount: params.amount.toString(),
|
|
703
|
+
metadata: { ...(params.metadata || {}), evmTxHash: txHash },
|
|
704
|
+
});
|
|
705
|
+
return finalResponse;
|
|
706
|
+
}
|
|
295
707
|
/**
|
|
296
708
|
* Show wallet connection modal
|
|
297
709
|
*/
|
|
@@ -424,182 +836,52 @@ class Icpay {
|
|
|
424
836
|
this.emitMethodStart('createPayment', { request: { ...request, amount: typeof request.amount === 'string' ? request.amount : String(request.amount) } });
|
|
425
837
|
try {
|
|
426
838
|
(0, utils_1.debugLog)(this.config.debug || false, 'createPayment start', { request });
|
|
427
|
-
// Resolve ledgerCanisterId from symbol if needed
|
|
839
|
+
// Resolve ledgerCanisterId from symbol if needed (legacy). If tokenShortcode provided, no resolution required.
|
|
428
840
|
let ledgerCanisterId = request.ledgerCanisterId;
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
}
|
|
432
|
-
if (!ledgerCanisterId) {
|
|
841
|
+
const tokenShortcode = request?.tokenShortcode;
|
|
842
|
+
if (!ledgerCanisterId && !tokenShortcode && !request.symbol) {
|
|
433
843
|
const err = new errors_1.IcpayError({
|
|
434
844
|
code: errors_1.ICPAY_ERROR_CODES.INVALID_CONFIG,
|
|
435
|
-
message: '
|
|
845
|
+
message: 'Provide either tokenShortcode or ledgerCanisterId (symbol is deprecated).',
|
|
436
846
|
details: { request }
|
|
437
847
|
});
|
|
438
848
|
this.emitMethodError('createPayment', err);
|
|
439
849
|
throw err;
|
|
440
850
|
}
|
|
441
|
-
// Fetch account info to get accountCanisterId if not provided
|
|
442
|
-
let accountCanisterId = request.accountCanisterId;
|
|
443
|
-
if (!accountCanisterId) {
|
|
444
|
-
(0, utils_1.debugLog)(this.config.debug || false, 'fetching account info for accountCanisterId');
|
|
445
|
-
const accountInfo = await this.getAccountInfo();
|
|
446
|
-
accountCanisterId = accountInfo.accountCanisterId.toString();
|
|
447
|
-
(0, utils_1.debugLog)(this.config.debug || false, 'accountCanisterId resolved', { accountCanisterId });
|
|
448
|
-
}
|
|
449
|
-
// Always use icpayCanisterId as toPrincipal
|
|
450
|
-
if (!this.icpayCanisterId) {
|
|
451
|
-
await this.fetchAccountInfo();
|
|
452
|
-
}
|
|
453
|
-
// Fallback: try public getAccountInfo if still missing
|
|
454
|
-
if (!this.icpayCanisterId) {
|
|
455
|
-
try {
|
|
456
|
-
const acct = await this.getAccountInfo();
|
|
457
|
-
if (acct?.icpayCanisterId) {
|
|
458
|
-
this.icpayCanisterId = acct.icpayCanisterId.toString();
|
|
459
|
-
}
|
|
460
|
-
}
|
|
461
|
-
catch { }
|
|
462
|
-
}
|
|
463
|
-
if (!this.icpayCanisterId || typeof this.icpayCanisterId !== 'string') {
|
|
464
|
-
const err = new errors_1.IcpayError({
|
|
465
|
-
code: errors_1.ICPAY_ERROR_CODES.INVALID_CONFIG,
|
|
466
|
-
message: 'Could not resolve ICPay canister ID from account info',
|
|
467
|
-
details: { accountInfoCache: this.accountInfoCache }
|
|
468
|
-
});
|
|
469
|
-
this.emitMethodError('createPayment', err);
|
|
470
|
-
throw err;
|
|
471
|
-
}
|
|
472
|
-
const toPrincipal = this.icpayCanisterId;
|
|
473
|
-
const host = this.icHost;
|
|
474
851
|
let memo = undefined;
|
|
475
|
-
// If onrampPayment is enabled (request or global config), branch to onramp flow
|
|
476
|
-
const onramp = (request.onrampPayment === true || this.config.onrampPayment === true) && this.config.onrampDisabled !== true ? true : false;
|
|
477
|
-
if (onramp) {
|
|
478
|
-
// Only ICP ledger is allowed for onramp
|
|
479
|
-
if (ledgerCanisterId !== this.icpLedgerCanisterId) {
|
|
480
|
-
const err = new errors_1.IcpayError({
|
|
481
|
-
code: errors_1.ICPAY_ERROR_CODES.INVALID_CONFIG,
|
|
482
|
-
message: 'Onramp is only supported for ICP ledger',
|
|
483
|
-
details: { provided: ledgerCanisterId, expected: this.icpLedgerCanisterId },
|
|
484
|
-
});
|
|
485
|
-
this.emitError(err);
|
|
486
|
-
throw err;
|
|
487
|
-
}
|
|
488
|
-
// Ensure amountUsd is provided or compute it
|
|
489
|
-
let amountUsd = request.amountUsd;
|
|
490
|
-
if (amountUsd == null) {
|
|
491
|
-
try {
|
|
492
|
-
const res = await this.calculateTokenAmountFromUSD({
|
|
493
|
-
usdAmount: 1, // placeholder to fetch price
|
|
494
|
-
ledgerCanisterId,
|
|
495
|
-
});
|
|
496
|
-
// If price is P = USD per token, then amountUsd = amountTokens * P
|
|
497
|
-
const price = res.currentPrice;
|
|
498
|
-
const tokenAmount = typeof request.amount === 'string' ? Number(request.amount) : Number(request.amount);
|
|
499
|
-
amountUsd = price * (tokenAmount / Math.pow(10, res.decimals));
|
|
500
|
-
}
|
|
501
|
-
catch { }
|
|
502
|
-
}
|
|
503
|
-
// Create payment intent directly (without requiring connected wallet), flagging onrampPayment
|
|
504
|
-
let paymentIntentId = null;
|
|
505
|
-
let paymentIntentCode = null;
|
|
506
|
-
try {
|
|
507
|
-
(0, utils_1.debugLog)(this.config.debug || false, 'creating onramp payment intent');
|
|
508
|
-
const intentResp = await this.publicApiClient.post('/sdk/public/payments/intents', {
|
|
509
|
-
amount: request.amount,
|
|
510
|
-
symbol: request.symbol,
|
|
511
|
-
ledgerCanisterId,
|
|
512
|
-
// expectedSenderPrincipal omitted in onramp
|
|
513
|
-
metadata: request.metadata || {},
|
|
514
|
-
onrampPayment: true,
|
|
515
|
-
widgetParams: request.widgetParams || {},
|
|
516
|
-
amountUsd: typeof amountUsd === 'string' ? amountUsd : (amountUsd != null ? amountUsd.toFixed(2) : undefined),
|
|
517
|
-
});
|
|
518
|
-
paymentIntentId = intentResp?.paymentIntentId || intentResp?.paymentIntent?.id || null;
|
|
519
|
-
paymentIntentCode = intentResp?.paymentIntentCode ?? null;
|
|
520
|
-
const onrampData = intentResp?.onramp || {};
|
|
521
|
-
// Return minimally required response and attach onramp data for widget init
|
|
522
|
-
return {
|
|
523
|
-
transactionId: 0,
|
|
524
|
-
status: 'pending',
|
|
525
|
-
amount: request.amount,
|
|
526
|
-
recipientCanister: this.icpayCanisterId,
|
|
527
|
-
timestamp: new Date(),
|
|
528
|
-
metadata: {
|
|
529
|
-
paymentIntentId,
|
|
530
|
-
paymentIntentCode,
|
|
531
|
-
onramp: onrampData,
|
|
532
|
-
},
|
|
533
|
-
};
|
|
534
|
-
}
|
|
535
|
-
catch (e) {
|
|
536
|
-
const err = new errors_1.IcpayError({
|
|
537
|
-
code: errors_1.ICPAY_ERROR_CODES.API_ERROR,
|
|
538
|
-
message: 'Failed to create onramp payment intent',
|
|
539
|
-
details: e,
|
|
540
|
-
retryable: true,
|
|
541
|
-
userAction: 'Try again',
|
|
542
|
-
});
|
|
543
|
-
this.emitError(err);
|
|
544
|
-
throw err;
|
|
545
|
-
}
|
|
546
|
-
}
|
|
547
|
-
// Pre-flight: compute required amount for balance check before creating intent to avoid dangling intents
|
|
548
|
-
let preAmountStr = typeof request.amount === 'string' ? request.amount : (request.amount != null ? String(request.amount) : undefined);
|
|
549
|
-
if (!preAmountStr && request.amountUsd != null) {
|
|
550
|
-
try {
|
|
551
|
-
const calc = await this.calculateTokenAmountFromUSD({ usdAmount: Number(request.amountUsd), ledgerCanisterId });
|
|
552
|
-
preAmountStr = calc.tokenAmountDecimals;
|
|
553
|
-
}
|
|
554
|
-
catch { }
|
|
555
|
-
}
|
|
556
|
-
if (!preAmountStr) {
|
|
557
|
-
const err = new errors_1.IcpayError({ code: errors_1.ICPAY_ERROR_CODES.API_ERROR, message: 'Either amount or amountUsd must be provided' });
|
|
558
|
-
this.emitError(err);
|
|
559
|
-
throw err;
|
|
560
|
-
}
|
|
561
|
-
// Check balance before sending
|
|
562
|
-
const requiredAmount = BigInt(preAmountStr);
|
|
563
|
-
(0, utils_1.debugLog)(this.config.debug || false, 'checking balance', { ledgerCanisterId, requiredAmount: requiredAmount.toString() });
|
|
564
|
-
// Helper function to make amounts human-readable
|
|
565
|
-
const formatAmount = (amount, decimals = 8, symbol = '') => {
|
|
566
|
-
const divisor = BigInt(10 ** decimals);
|
|
567
|
-
const whole = amount / divisor;
|
|
568
|
-
const fraction = amount % divisor;
|
|
569
|
-
const fractionStr = fraction.toString().padStart(decimals, '0').replace(/0+$/, '');
|
|
570
|
-
return `${whole}${fractionStr ? '.' + fractionStr : ''} ${symbol}`.trim();
|
|
571
|
-
};
|
|
572
|
-
// Check if user has sufficient balance based on ledger type
|
|
573
|
-
try {
|
|
574
|
-
// Get the actual balance from the specific ledger (works for all ICRC ledgers including ICP)
|
|
575
|
-
const actualBalance = await this.getLedgerBalance(ledgerCanisterId);
|
|
576
|
-
if (actualBalance < requiredAmount) {
|
|
577
|
-
const requiredFormatted = formatAmount(requiredAmount, 8, 'tokens');
|
|
578
|
-
const availableFormatted = formatAmount(actualBalance, 8, 'tokens');
|
|
579
|
-
throw (0, errors_1.createBalanceError)(requiredFormatted, availableFormatted, {
|
|
580
|
-
required: requiredAmount,
|
|
581
|
-
available: actualBalance,
|
|
582
|
-
ledgerCanisterId
|
|
583
|
-
});
|
|
584
|
-
}
|
|
585
|
-
(0, utils_1.debugLog)(this.config.debug || false, 'balance ok', { actualBalance: actualBalance.toString() });
|
|
586
|
-
}
|
|
587
|
-
catch (balanceError) {
|
|
588
|
-
// If we can't fetch the specific ledger balance, fall back to the old logic
|
|
589
|
-
throw new errors_1.IcpayError({
|
|
590
|
-
code: 'INSUFFICIENT_BALANCE',
|
|
591
|
-
message: 'Insufficient balance',
|
|
592
|
-
details: { required: requiredAmount, available: 0 }
|
|
593
|
-
});
|
|
594
|
-
}
|
|
595
852
|
// 1) Create payment intent via API (backend will finalize amount/price)
|
|
596
853
|
let paymentIntentId = null;
|
|
597
854
|
let paymentIntentCode = null;
|
|
855
|
+
let intentChainType;
|
|
856
|
+
let intentChainId;
|
|
857
|
+
let accountCanisterId;
|
|
598
858
|
let resolvedAmountStr = typeof request.amount === 'string' ? request.amount : (request.amount != null ? String(request.amount) : undefined);
|
|
599
859
|
try {
|
|
600
860
|
(0, utils_1.debugLog)(this.config.debug || false, 'creating payment intent');
|
|
601
|
-
//
|
|
602
|
-
|
|
861
|
+
// Expected sender principal: allow override via request, fallback to connected wallet, then EVM provider
|
|
862
|
+
let expectedSenderPrincipal = request.expectedSenderPrincipal
|
|
863
|
+
|| this.connectedWallet?.owner
|
|
864
|
+
|| this.connectedWallet?.principal?.toString();
|
|
865
|
+
const evm = this.config?.evmProvider || globalThis?.ethereum;
|
|
866
|
+
if (evm?.request) {
|
|
867
|
+
try {
|
|
868
|
+
const accounts = await evm.request({ method: 'eth_accounts' });
|
|
869
|
+
if (Array.isArray(accounts) && accounts[0]) {
|
|
870
|
+
const lowerAccounts = accounts.map((a) => String(a).toLowerCase());
|
|
871
|
+
const providedRaw = request?.expectedSenderPrincipal;
|
|
872
|
+
if (providedRaw) {
|
|
873
|
+
const provided = String(providedRaw).toLowerCase();
|
|
874
|
+
expectedSenderPrincipal = lowerAccounts.includes(provided)
|
|
875
|
+
? accounts[lowerAccounts.indexOf(provided)]
|
|
876
|
+
: (expectedSenderPrincipal || accounts[0]);
|
|
877
|
+
}
|
|
878
|
+
else if (!expectedSenderPrincipal) {
|
|
879
|
+
expectedSenderPrincipal = accounts[0];
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
catch { }
|
|
884
|
+
}
|
|
603
885
|
if (!expectedSenderPrincipal) {
|
|
604
886
|
throw new errors_1.IcpayError({
|
|
605
887
|
code: errors_1.ICPAY_ERROR_CODES.WALLET_NOT_CONNECTED,
|
|
@@ -609,17 +891,39 @@ class Icpay {
|
|
|
609
891
|
userAction: 'Connect your wallet first'
|
|
610
892
|
});
|
|
611
893
|
}
|
|
894
|
+
const onramp = (request.onrampPayment === true || this.config.onrampPayment === true) && this.config.onrampDisabled !== true ? true : false;
|
|
612
895
|
const intentResp = await this.publicApiClient.post('/sdk/public/payments/intents', {
|
|
613
|
-
amount: request.amount,
|
|
614
|
-
|
|
615
|
-
|
|
896
|
+
amount: (typeof request.amount === 'string' ? request.amount : (request.amount != null ? String(request.amount) : undefined)),
|
|
897
|
+
// Prefer tokenShortcode if provided
|
|
898
|
+
tokenShortcode: tokenShortcode || undefined,
|
|
899
|
+
// Legacy fields for backwards compatibility
|
|
900
|
+
symbol: tokenShortcode ? undefined : request.symbol,
|
|
901
|
+
ledgerCanisterId: tokenShortcode ? undefined : ledgerCanisterId,
|
|
902
|
+
description: request.description,
|
|
616
903
|
expectedSenderPrincipal,
|
|
617
904
|
metadata: request.metadata || {},
|
|
618
905
|
amountUsd: request.amountUsd,
|
|
906
|
+
// With tokenShortcode, backend derives chain. Keep legacy chainId for old flows.
|
|
907
|
+
chainId: tokenShortcode ? undefined : request.chainId,
|
|
908
|
+
onrampPayment: onramp || undefined,
|
|
909
|
+
widgetParams: request.widgetParams || undefined,
|
|
619
910
|
});
|
|
620
911
|
paymentIntentId = intentResp?.paymentIntent?.id || null;
|
|
621
912
|
paymentIntentCode = intentResp?.paymentIntent?.intentCode ?? null;
|
|
622
913
|
resolvedAmountStr = intentResp?.paymentIntent?.amount || resolvedAmountStr;
|
|
914
|
+
intentChainType = intentResp?.paymentIntent?.chainType || intentResp?.paymentIntent?.networkType || intentResp?.chainType;
|
|
915
|
+
intentChainId = intentResp?.paymentIntent?.chainId || intentResp?.chainId || request.chainId;
|
|
916
|
+
const onrampData = intentResp?.onramp || null;
|
|
917
|
+
const contractAddress = intentResp?.paymentIntent?.contractAddress || null;
|
|
918
|
+
const rpcUrlPublic = intentResp?.paymentIntent?.rpcUrlPublic || null;
|
|
919
|
+
const chainNameFromIntent = intentResp?.paymentIntent?.chainName || null;
|
|
920
|
+
const rpcChainId = intentResp?.paymentIntent?.rpcChainId || null;
|
|
921
|
+
const functionSelectors = intentResp?.paymentIntent?.functionSelectors || null;
|
|
922
|
+
accountCanisterId = intentResp?.paymentIntent?.accountCanisterId || null;
|
|
923
|
+
// Backfill ledgerCanisterId from intent if not provided in request (tokenShortcode flow)
|
|
924
|
+
if (!ledgerCanisterId && intentResp?.paymentIntent?.ledgerCanisterId) {
|
|
925
|
+
ledgerCanisterId = intentResp.paymentIntent.ledgerCanisterId;
|
|
926
|
+
}
|
|
623
927
|
(0, utils_1.debugLog)(this.config.debug || false, 'payment intent created', { paymentIntentId, paymentIntentCode, expectedSenderPrincipal, resolvedAmountStr });
|
|
624
928
|
// Emit transaction created event
|
|
625
929
|
if (paymentIntentId) {
|
|
@@ -627,9 +931,16 @@ class Icpay {
|
|
|
627
931
|
paymentIntentId,
|
|
628
932
|
amount: resolvedAmountStr,
|
|
629
933
|
ledgerCanisterId,
|
|
630
|
-
expectedSenderPrincipal
|
|
934
|
+
expectedSenderPrincipal,
|
|
935
|
+
accountCanisterId,
|
|
631
936
|
});
|
|
632
937
|
}
|
|
938
|
+
request.__onramp = onrampData;
|
|
939
|
+
request.__contractAddress = contractAddress;
|
|
940
|
+
request.__rpcUrlPublic = rpcUrlPublic;
|
|
941
|
+
request.__chainName = chainNameFromIntent;
|
|
942
|
+
request.__functionSelectors = functionSelectors;
|
|
943
|
+
request.__rpcChainId = rpcChainId;
|
|
633
944
|
}
|
|
634
945
|
catch (e) {
|
|
635
946
|
// Do not proceed without a payment intent
|
|
@@ -652,108 +963,31 @@ class Icpay {
|
|
|
652
963
|
throw err;
|
|
653
964
|
}
|
|
654
965
|
const amount = BigInt(resolvedAmountStr);
|
|
655
|
-
// Build packed memo
|
|
966
|
+
// Build packed memo
|
|
656
967
|
const acctIdNum = parseInt(accountCanisterId);
|
|
657
968
|
if (!isNaN(acctIdNum) && paymentIntentCode != null) {
|
|
658
969
|
memo = this.createPackedMemo(acctIdNum, Number(paymentIntentCode));
|
|
659
970
|
(0, utils_1.debugLog)(this.config.debug || false, 'built packed memo', { accountCanisterId: acctIdNum, paymentIntentCode });
|
|
660
971
|
}
|
|
661
|
-
(0, utils_1.debugLog)(this.config.debug || false, 'memo', { memo });
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
transferResult = await this.sendFundsToLedger(ledgerCanisterId, toPrincipal, amount, memo, host);
|
|
667
|
-
}
|
|
668
|
-
catch (transferError) {
|
|
669
|
-
// Some wallets/networks return a timeout or transient 5xx even when the transfer was accepted.
|
|
670
|
-
// Treat these as processing and continue with intent notification so users don't double-send.
|
|
671
|
-
const msg = String(transferError?.message || '');
|
|
672
|
-
const lower = msg.toLowerCase();
|
|
673
|
-
const isTimeout = lower.includes('request timed out');
|
|
674
|
-
const isProcessing = isTimeout && lower.includes('processing');
|
|
675
|
-
// DFINITY HTTP agent transient error when subnet has no healthy nodes (e.g., during upgrade)
|
|
676
|
-
const isNoHealthyNodes = lower.includes('no_healthy_nodes') || lower.includes('service unavailable') || lower.includes('503');
|
|
677
|
-
// Plug inpage transport sometimes throws readState errors after a signed call even though the tx went through
|
|
678
|
-
const isPlugReadState = lower.includes('read state request') || lower.includes('readstate') || lower.includes('response could not be found');
|
|
679
|
-
if (isTimeout || isProcessing || isNoHealthyNodes || isPlugReadState) {
|
|
680
|
-
(0, utils_1.debugLog)(this.config.debug || false, 'transfer timed out, proceeding with intent notification', { message: msg });
|
|
681
|
-
// Long-poll the public notify endpoint using only the intent id (no canister tx id available)
|
|
682
|
-
const publicNotify = await this.performNotifyPaymentIntent({
|
|
683
|
-
paymentIntentId: paymentIntentId,
|
|
684
|
-
maxAttempts: 120,
|
|
685
|
-
delayMs: 1000,
|
|
686
|
-
});
|
|
687
|
-
// Derive status from API response
|
|
688
|
-
let statusString = 'pending';
|
|
689
|
-
const apiStatus = publicNotify?.paymentIntent?.status || publicNotify?.payment?.status || publicNotify?.status;
|
|
690
|
-
if (typeof apiStatus === 'string') {
|
|
691
|
-
const norm = apiStatus.toLowerCase();
|
|
692
|
-
if (norm === 'completed' || norm === 'succeeded')
|
|
693
|
-
statusString = 'completed';
|
|
694
|
-
else if (norm === 'failed' || norm === 'canceled' || norm === 'cancelled')
|
|
695
|
-
statusString = 'failed';
|
|
696
|
-
}
|
|
697
|
-
const response = {
|
|
698
|
-
transactionId: 0,
|
|
699
|
-
status: statusString,
|
|
700
|
-
amount: amount.toString(),
|
|
701
|
-
recipientCanister: ledgerCanisterId,
|
|
702
|
-
timestamp: new Date(),
|
|
703
|
-
description: 'Fund transfer',
|
|
704
|
-
metadata: request.metadata,
|
|
705
|
-
payment: publicNotify,
|
|
706
|
-
};
|
|
707
|
-
if (statusString === 'completed') {
|
|
708
|
-
const requested = publicNotify?.payment?.requestedAmount || null;
|
|
709
|
-
const paid = publicNotify?.payment?.paidAmount || null;
|
|
710
|
-
const isMismatched = publicNotify?.payment?.status === 'mismatched';
|
|
711
|
-
if (isMismatched) {
|
|
712
|
-
this.emit('icpay-sdk-transaction-mismatched', { ...response, requestedAmount: requested, paidAmount: paid });
|
|
713
|
-
this.emit('icpay-sdk-transaction-updated', { ...response, status: 'mismatched', requestedAmount: requested, paidAmount: paid });
|
|
714
|
-
}
|
|
715
|
-
else {
|
|
716
|
-
this.emit('icpay-sdk-transaction-completed', response);
|
|
717
|
-
}
|
|
718
|
-
}
|
|
719
|
-
else if (statusString === 'failed') {
|
|
720
|
-
this.emit('icpay-sdk-transaction-failed', response);
|
|
721
|
-
}
|
|
722
|
-
else {
|
|
723
|
-
this.emit('icpay-sdk-transaction-updated', response);
|
|
724
|
-
}
|
|
725
|
-
this.emitMethodSuccess('createPayment', response);
|
|
726
|
-
return response;
|
|
727
|
-
}
|
|
728
|
-
throw transferError;
|
|
729
|
-
}
|
|
730
|
-
// Assume transferResult returns a block index or transaction id
|
|
731
|
-
const blockIndex = transferResult?.Ok?.toString() || transferResult?.blockIndex?.toString() || `temp-${Date.now()}`;
|
|
732
|
-
(0, utils_1.debugLog)(this.config.debug || false, 'transfer result', { blockIndex });
|
|
733
|
-
// First, notify the canister about the ledger transaction (best-effort)
|
|
734
|
-
let canisterTransactionId;
|
|
735
|
-
try {
|
|
736
|
-
(0, utils_1.debugLog)(this.config.debug || false, 'notifying canister about ledger tx');
|
|
737
|
-
const notifyRes = await this.notifyLedgerTransaction(this.icpayCanisterId, ledgerCanisterId, BigInt(blockIndex));
|
|
738
|
-
if (typeof notifyRes === 'string') {
|
|
739
|
-
canisterTransactionId = parseInt(notifyRes, 10);
|
|
740
|
-
}
|
|
741
|
-
else {
|
|
742
|
-
canisterTransactionId = parseInt(notifyRes.id, 10);
|
|
743
|
-
}
|
|
744
|
-
(0, utils_1.debugLog)(this.config.debug || false, 'canister notified', { canisterTransactionId });
|
|
745
|
-
}
|
|
746
|
-
catch (notifyError) {
|
|
747
|
-
canisterTransactionId = parseInt(blockIndex, 10);
|
|
748
|
-
(0, utils_1.debugLog)(this.config.debug || false, 'notify failed, using blockIndex as tx id', { canisterTransactionId });
|
|
749
|
-
}
|
|
750
|
-
// Durable wait until API returns terminal status (completed/mismatched/failed/canceled)
|
|
751
|
-
const finalResponse = await this.awaitIntentTerminal({
|
|
752
|
-
paymentIntentId: paymentIntentId,
|
|
753
|
-
canisterTransactionId: canisterTransactionId?.toString(),
|
|
972
|
+
(0, utils_1.debugLog)(this.config.debug || false, 'memo', { memo, accountCanisterId, paymentIntentCode });
|
|
973
|
+
// Delegate to chain-specific processing
|
|
974
|
+
const finalResponse = await this.processPaymentByChain({
|
|
975
|
+
chainType: intentChainType,
|
|
976
|
+
chainId: intentChainId,
|
|
754
977
|
ledgerCanisterId,
|
|
755
|
-
amount
|
|
978
|
+
amount,
|
|
979
|
+
memo,
|
|
980
|
+
paymentIntentId: paymentIntentId,
|
|
981
|
+
request,
|
|
982
|
+
resolvedAmountStr: amount.toString(),
|
|
756
983
|
metadata: request.metadata,
|
|
984
|
+
onrampData: request.__onramp,
|
|
985
|
+
contractAddress: request.__contractAddress,
|
|
986
|
+
accountCanisterId,
|
|
987
|
+
rpcUrlPublic: request.__rpcUrlPublic,
|
|
988
|
+
chainName: request.__chainName,
|
|
989
|
+
rpcChainId: request.__rpcChainId,
|
|
990
|
+
paymentIntentCode,
|
|
757
991
|
});
|
|
758
992
|
this.emitMethodSuccess('createPayment', finalResponse);
|
|
759
993
|
return finalResponse;
|
|
@@ -881,6 +1115,7 @@ class Icpay {
|
|
|
881
1115
|
const agent = new agent_1.HttpAgent({ host: this.icHost });
|
|
882
1116
|
const actor = agent_1.Actor.createActor(icpay_canister_backend_did_js_1.idlFactory, { agent, canisterId });
|
|
883
1117
|
result = await actor.notify_ledger_transaction({
|
|
1118
|
+
// Canister expects text for ledger_canister_id
|
|
884
1119
|
ledger_canister_id: ledgerCanisterId,
|
|
885
1120
|
block_index: blockIndex
|
|
886
1121
|
});
|
|
@@ -1004,68 +1239,67 @@ class Icpay {
|
|
|
1004
1239
|
}
|
|
1005
1240
|
// ===== NEW ENHANCED SDK FUNCTIONS =====
|
|
1006
1241
|
/**
|
|
1007
|
-
* Get
|
|
1242
|
+
* Public: Get balances for an external wallet (IC principal or EVM address) using publishable key
|
|
1008
1243
|
*/
|
|
1009
|
-
async
|
|
1010
|
-
this.emitMethodStart('
|
|
1244
|
+
async getExternalWalletBalances(params) {
|
|
1245
|
+
this.emitMethodStart('getExternalWalletBalances', { params });
|
|
1011
1246
|
try {
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
balance: rawBalance.toString(),
|
|
1031
|
-
formattedBalance,
|
|
1032
|
-
decimals: ledger.decimals,
|
|
1033
|
-
currentPrice: ledger.currentPrice || undefined,
|
|
1034
|
-
lastPriceUpdate: ledger.lastPriceUpdate ? new Date(ledger.lastPriceUpdate) : undefined,
|
|
1035
|
-
lastUpdated: new Date()
|
|
1036
|
-
};
|
|
1037
|
-
balances.push(balance);
|
|
1038
|
-
// Calculate USD value if price is available
|
|
1039
|
-
if (ledger.currentPrice && rawBalance > 0) {
|
|
1040
|
-
const humanReadableBalance = parseFloat(formattedBalance);
|
|
1041
|
-
totalBalancesUSD += humanReadableBalance * ledger.currentPrice;
|
|
1042
|
-
}
|
|
1043
|
-
}
|
|
1044
|
-
catch (error) {
|
|
1045
|
-
this.emit('icpay-sdk-method-error', {
|
|
1046
|
-
name: 'getAllLedgerBalances.getLedgerBalance',
|
|
1047
|
-
error,
|
|
1048
|
-
ledgerSymbol: ledger.symbol,
|
|
1049
|
-
ledgerCanisterId: ledger.canisterId
|
|
1050
|
-
});
|
|
1051
|
-
// Continue with other ledgers even if one fails
|
|
1052
|
-
}
|
|
1053
|
-
}
|
|
1247
|
+
const search = new URLSearchParams();
|
|
1248
|
+
if (params.network)
|
|
1249
|
+
search.set('network', params.network);
|
|
1250
|
+
if (params.address)
|
|
1251
|
+
search.set('address', params.address);
|
|
1252
|
+
if (params.principal)
|
|
1253
|
+
search.set('principal', params.principal);
|
|
1254
|
+
if (params.chainId)
|
|
1255
|
+
search.set('chainId', params.chainId);
|
|
1256
|
+
if (typeof params.amountUsd === 'number' && isFinite(params.amountUsd))
|
|
1257
|
+
search.set('amountUsd', String(params.amountUsd));
|
|
1258
|
+
if (typeof params.amount === 'string' && params.amount)
|
|
1259
|
+
search.set('amount', params.amount);
|
|
1260
|
+
if (Array.isArray(params.chainShortcodes) && params.chainShortcodes.length > 0)
|
|
1261
|
+
search.set('chainShortcodes', params.chainShortcodes.join(','));
|
|
1262
|
+
if (Array.isArray(params.tokenShortcodes) && params.tokenShortcodes.length > 0)
|
|
1263
|
+
search.set('tokenShortcodes', params.tokenShortcodes.join(','));
|
|
1264
|
+
const response = await this.publicApiClient.get(`/sdk/public/wallet/external-balances?${search.toString()}`);
|
|
1054
1265
|
const result = {
|
|
1055
|
-
balances
|
|
1056
|
-
|
|
1057
|
-
|
|
1266
|
+
balances: (response?.balances || []).map((balance) => ({
|
|
1267
|
+
ledgerId: balance.ledgerId,
|
|
1268
|
+
ledgerName: balance.ledgerName,
|
|
1269
|
+
ledgerSymbol: balance.ledgerSymbol,
|
|
1270
|
+
tokenShortcode: (balance?.tokenShortcode ?? balance?.shortcode) ?? null,
|
|
1271
|
+
canisterId: balance.canisterId,
|
|
1272
|
+
eip3009Version: balance?.eip3009Version ?? null,
|
|
1273
|
+
x402Accepts: balance?.x402Accepts != null ? Boolean(balance.x402Accepts) : undefined,
|
|
1274
|
+
balance: balance.balance,
|
|
1275
|
+
formattedBalance: balance.formattedBalance,
|
|
1276
|
+
decimals: balance.decimals,
|
|
1277
|
+
currentPrice: balance.currentPrice,
|
|
1278
|
+
lastPriceUpdate: balance.lastPriceUpdate ? new Date(balance.lastPriceUpdate) : undefined,
|
|
1279
|
+
lastUpdated: balance.lastUpdated ? new Date(balance.lastUpdated) : new Date(),
|
|
1280
|
+
// Chain metadata passthrough for multichain UX
|
|
1281
|
+
chainId: typeof balance.chainId === 'string' ? balance.chainId : (typeof balance.chainId === 'number' ? String(balance.chainId) : undefined),
|
|
1282
|
+
chainName: balance.chainName ?? (balance.chain && (balance.chain.name || balance.chain.chainName)) ?? null,
|
|
1283
|
+
rpcUrlPublic: balance.rpcUrlPublic ?? null,
|
|
1284
|
+
chainUuid: balance.chainUuid ?? null,
|
|
1285
|
+
requiredAmount: balance.requiredAmount,
|
|
1286
|
+
requiredAmountFormatted: balance.requiredAmountFormatted,
|
|
1287
|
+
hasSufficientBalance: balance.hasSufficientBalance,
|
|
1288
|
+
logoUrl: balance.logoUrl ?? null,
|
|
1289
|
+
})),
|
|
1290
|
+
totalBalancesUSD: response?.totalBalancesUSD,
|
|
1291
|
+
lastUpdated: new Date(response?.lastUpdated || Date.now()),
|
|
1058
1292
|
};
|
|
1059
|
-
this.emitMethodSuccess('
|
|
1293
|
+
this.emitMethodSuccess('getExternalWalletBalances', { count: result.balances.length, totalUSD: result.totalBalancesUSD });
|
|
1060
1294
|
return result;
|
|
1061
1295
|
}
|
|
1062
1296
|
catch (error) {
|
|
1063
1297
|
const err = new errors_1.IcpayError({
|
|
1064
|
-
code: '
|
|
1065
|
-
message: 'Failed to fetch
|
|
1066
|
-
details: error
|
|
1298
|
+
code: 'ACCOUNT_WALLET_BALANCES_FETCH_FAILED',
|
|
1299
|
+
message: 'Failed to fetch external wallet balances (public)',
|
|
1300
|
+
details: error,
|
|
1067
1301
|
});
|
|
1068
|
-
this.emitMethodError('
|
|
1302
|
+
this.emitMethodError('getExternalWalletBalances', err);
|
|
1069
1303
|
throw err;
|
|
1070
1304
|
}
|
|
1071
1305
|
}
|
|
@@ -1177,14 +1411,33 @@ class Icpay {
|
|
|
1177
1411
|
/**
|
|
1178
1412
|
* Get detailed ledger information including price data (public method)
|
|
1179
1413
|
*/
|
|
1180
|
-
async getLedgerInfo(ledgerCanisterId) {
|
|
1181
|
-
this.emitMethodStart('getLedgerInfo', { ledgerCanisterId });
|
|
1414
|
+
async getLedgerInfo(ledgerCanisterId, opts) {
|
|
1415
|
+
this.emitMethodStart('getLedgerInfo', { ledgerCanisterId, opts });
|
|
1182
1416
|
try {
|
|
1183
|
-
const
|
|
1417
|
+
const isZeroAddress = typeof ledgerCanisterId === 'string' && /^0x0{40}$/i.test(ledgerCanisterId);
|
|
1418
|
+
// Back-compat safety: require chainId for native token (zero address)
|
|
1419
|
+
let url = `/sdk/public/ledgers/${encodeURIComponent(ledgerCanisterId)}`;
|
|
1420
|
+
if (isZeroAddress) {
|
|
1421
|
+
const chainId = opts?.chainId;
|
|
1422
|
+
if (!chainId && this.config?.debug) {
|
|
1423
|
+
(0, utils_1.debugLog)(true, 'getLedgerInfo requires chainId for zero address', { ledgerCanisterId });
|
|
1424
|
+
}
|
|
1425
|
+
if (!chainId) {
|
|
1426
|
+
throw new errors_1.IcpayError({
|
|
1427
|
+
code: errors_1.ICPAY_ERROR_CODES.INVALID_CONFIG,
|
|
1428
|
+
message: 'chainId is required when querying native token (0x000…000). Prefer tokenShortcode in new flows.',
|
|
1429
|
+
});
|
|
1430
|
+
}
|
|
1431
|
+
const chainStr = typeof chainId === 'number' ? String(chainId) : chainId;
|
|
1432
|
+
url = `${url}?chainId=${encodeURIComponent(chainStr || '')}`;
|
|
1433
|
+
}
|
|
1434
|
+
const ledger = await this.publicApiClient.get(url);
|
|
1184
1435
|
const result = {
|
|
1185
1436
|
id: ledger.id,
|
|
1186
1437
|
name: ledger.name,
|
|
1187
1438
|
symbol: ledger.symbol,
|
|
1439
|
+
chainId: ledger.chainId,
|
|
1440
|
+
shortcode: ledger.shortcode ?? null,
|
|
1188
1441
|
canisterId: ledger.canisterId,
|
|
1189
1442
|
standard: ledger.standard,
|
|
1190
1443
|
decimals: ledger.decimals,
|
|
@@ -1222,28 +1475,29 @@ class Icpay {
|
|
|
1222
1475
|
try {
|
|
1223
1476
|
// Convert usdAmount to number if it's a string
|
|
1224
1477
|
const usdAmount = typeof request.usdAmount === 'string' ? parseFloat(request.usdAmount) : request.usdAmount;
|
|
1225
|
-
//
|
|
1478
|
+
// If tokenShortcode provided, skip canister resolution; otherwise resolve from symbol if needed
|
|
1479
|
+
const tokenShortcode = request?.tokenShortcode;
|
|
1226
1480
|
let ledgerCanisterId = request.ledgerCanisterId;
|
|
1227
|
-
if (!ledgerCanisterId && request.symbol) {
|
|
1228
|
-
ledgerCanisterId = await this.getLedgerCanisterIdBySymbol(request.symbol);
|
|
1229
|
-
}
|
|
1230
|
-
if (!ledgerCanisterId) {
|
|
1481
|
+
if (!ledgerCanisterId && !tokenShortcode && !request.symbol) {
|
|
1231
1482
|
const err = new errors_1.IcpayError({
|
|
1232
1483
|
code: errors_1.ICPAY_ERROR_CODES.INVALID_CONFIG,
|
|
1233
|
-
message: '
|
|
1484
|
+
message: 'Provide either tokenShortcode or ledgerCanisterId (symbol is deprecated).',
|
|
1234
1485
|
details: { request }
|
|
1235
1486
|
});
|
|
1236
|
-
this.emitMethodError('
|
|
1487
|
+
this.emitMethodError('createPayment', err);
|
|
1237
1488
|
throw err;
|
|
1238
1489
|
}
|
|
1239
1490
|
const createTransactionRequest = {
|
|
1240
|
-
ledgerCanisterId,
|
|
1241
|
-
symbol: request.symbol,
|
|
1491
|
+
ledgerCanisterId: tokenShortcode ? undefined : ledgerCanisterId,
|
|
1492
|
+
symbol: tokenShortcode ? undefined : request.symbol,
|
|
1493
|
+
tokenShortcode,
|
|
1242
1494
|
amountUsd: usdAmount,
|
|
1495
|
+
description: request.description,
|
|
1243
1496
|
accountCanisterId: request.accountCanisterId,
|
|
1244
1497
|
metadata: request.metadata,
|
|
1245
1498
|
onrampPayment: request.onrampPayment,
|
|
1246
1499
|
widgetParams: request.widgetParams,
|
|
1500
|
+
chainId: tokenShortcode ? undefined : request.chainId,
|
|
1247
1501
|
};
|
|
1248
1502
|
const res = await this.createPayment(createTransactionRequest);
|
|
1249
1503
|
this.emitMethodSuccess('createPaymentUsd', res);
|
|
@@ -1263,6 +1517,212 @@ class Icpay {
|
|
|
1263
1517
|
throw err;
|
|
1264
1518
|
}
|
|
1265
1519
|
}
|
|
1520
|
+
/**
|
|
1521
|
+
* Create an X402 payment from a USD amount
|
|
1522
|
+
* Falls back to regular flow at caller level if unavailable.
|
|
1523
|
+
*/
|
|
1524
|
+
async createPaymentX402Usd(request) {
|
|
1525
|
+
this.emitMethodStart('createPaymentX402Usd', { request });
|
|
1526
|
+
try {
|
|
1527
|
+
const usdAmount = typeof request.usdAmount === 'string' ? parseFloat(request.usdAmount) : request.usdAmount;
|
|
1528
|
+
// For X402, the backend will resolve ledger/symbol as needed from the intent.
|
|
1529
|
+
// We forward both amountUsd and amount (if provided), and do not resolve canister here.
|
|
1530
|
+
const ledgerCanisterId = request.ledgerCanisterId || '';
|
|
1531
|
+
const tokenShortcode = request?.tokenShortcode;
|
|
1532
|
+
// Hit X402 endpoint
|
|
1533
|
+
const body = {
|
|
1534
|
+
amount: request.amount,
|
|
1535
|
+
amountUsd: usdAmount,
|
|
1536
|
+
// Prefer tokenShortcode; keep legacy fields if not provided
|
|
1537
|
+
tokenShortcode: tokenShortcode || undefined,
|
|
1538
|
+
symbol: tokenShortcode ? undefined : request.symbol,
|
|
1539
|
+
ledgerCanisterId: tokenShortcode ? undefined : ledgerCanisterId,
|
|
1540
|
+
description: request.description,
|
|
1541
|
+
metadata: request.metadata,
|
|
1542
|
+
chainId: tokenShortcode ? undefined : request.chainId,
|
|
1543
|
+
x402: true,
|
|
1544
|
+
};
|
|
1545
|
+
try {
|
|
1546
|
+
const resp = await this.publicApiClient.post('/sdk/public/payments/intents/x402', body);
|
|
1547
|
+
// If backend indicates x402 is unavailable (failed + fallback), immediately switch to normal flow
|
|
1548
|
+
const respStatus = (resp?.status || '').toString().toLowerCase();
|
|
1549
|
+
const fallbackSuggested = Boolean(resp?.fallbackSuggested);
|
|
1550
|
+
if (respStatus === 'failed' && fallbackSuggested) {
|
|
1551
|
+
const fallback = await this.createPaymentUsd(request);
|
|
1552
|
+
this.emitMethodSuccess('createPaymentX402Usd', fallback);
|
|
1553
|
+
return fallback;
|
|
1554
|
+
}
|
|
1555
|
+
// If backend returned normal flow (no accepts), skip x402 and proceed with regular flow
|
|
1556
|
+
const hasAccepts = Array.isArray(resp?.accepts) && resp.accepts.length > 0;
|
|
1557
|
+
if (!hasAccepts) {
|
|
1558
|
+
const fallback = await this.createPaymentUsd(request);
|
|
1559
|
+
this.emitMethodSuccess('createPaymentX402Usd', fallback);
|
|
1560
|
+
return fallback;
|
|
1561
|
+
}
|
|
1562
|
+
// If backend returned accepts despite 200, keep previous behavior (pending with x402 metadata)
|
|
1563
|
+
const normalized = {
|
|
1564
|
+
transactionId: 0,
|
|
1565
|
+
status: 'pending',
|
|
1566
|
+
amount: (resp?.paymentIntent?.amount || resp?.amount || request.usdAmount)?.toString?.() || String(request.usdAmount),
|
|
1567
|
+
recipientCanister: ledgerCanisterId,
|
|
1568
|
+
timestamp: new Date(),
|
|
1569
|
+
metadata: { ...(request.metadata || {}), x402: true },
|
|
1570
|
+
payment: resp,
|
|
1571
|
+
};
|
|
1572
|
+
this.emitMethodSuccess('createPaymentX402Usd', normalized);
|
|
1573
|
+
return normalized;
|
|
1574
|
+
}
|
|
1575
|
+
catch (e) {
|
|
1576
|
+
// If API responds with HTTP 402 to trigger wallet X402 flow, begin settlement wait instead of erroring
|
|
1577
|
+
if (e && typeof e.status === 'number' && e.status === 402) {
|
|
1578
|
+
// Try to extract paymentIntentId from response data to start polling
|
|
1579
|
+
const data = e?.data || {};
|
|
1580
|
+
// Support new x402 Payment Required Response body:
|
|
1581
|
+
// { x402Version, accepts: [{ ..., extra: { intentId } }], error }
|
|
1582
|
+
let paymentIntentId = data?.paymentIntentId || null;
|
|
1583
|
+
if (!paymentIntentId && Array.isArray(data?.accepts) && data.accepts[0]?.extra?.intentId) {
|
|
1584
|
+
paymentIntentId = String(data.accepts[0].extra.intentId);
|
|
1585
|
+
}
|
|
1586
|
+
if (paymentIntentId) {
|
|
1587
|
+
// Prefer ledgerCanisterId from request/body; fallback to server response if present
|
|
1588
|
+
const acceptsArr = Array.isArray(data?.accepts) ? data.accepts : [];
|
|
1589
|
+
let requirement = acceptsArr.length > 0 ? acceptsArr[0] : null;
|
|
1590
|
+
if (requirement) {
|
|
1591
|
+
try {
|
|
1592
|
+
const paymentHeader = await (0, builders_1.buildAndSignX402PaymentHeader)(requirement, {
|
|
1593
|
+
x402Version: Number(data?.x402Version || 1),
|
|
1594
|
+
debug: this.config?.debug || false,
|
|
1595
|
+
provider: this.config?.evmProvider || (typeof globalThis?.ethereum !== 'undefined' ? globalThis.ethereum : undefined),
|
|
1596
|
+
});
|
|
1597
|
+
// Start verification stage while we wait for settlement to process
|
|
1598
|
+
try {
|
|
1599
|
+
this.emitMethodStart('notifyLedgerTransaction', { paymentIntentId });
|
|
1600
|
+
}
|
|
1601
|
+
catch { }
|
|
1602
|
+
const settleResp = await this.publicApiClient.post('/sdk/public/payments/x402/settle', {
|
|
1603
|
+
paymentIntentId,
|
|
1604
|
+
paymentHeader,
|
|
1605
|
+
paymentRequirements: requirement,
|
|
1606
|
+
});
|
|
1607
|
+
try {
|
|
1608
|
+
(0, utils_1.debugLog)(this.config?.debug || false, 'x402 settle response (from icpay-services via api)', {
|
|
1609
|
+
ok: settleResp?.ok,
|
|
1610
|
+
status: settleResp?.status,
|
|
1611
|
+
txHash: settleResp?.txHash,
|
|
1612
|
+
paymentIntentId: settleResp?.paymentIntent?.id,
|
|
1613
|
+
paymentId: settleResp?.payment?.id,
|
|
1614
|
+
rawKeys: Object.keys(settleResp || {}),
|
|
1615
|
+
});
|
|
1616
|
+
}
|
|
1617
|
+
catch { }
|
|
1618
|
+
// Move to "Payment confirmation" stage (confirm loading)
|
|
1619
|
+
try {
|
|
1620
|
+
this.emitMethodSuccess('notifyLedgerTransaction', { paymentIntentId });
|
|
1621
|
+
}
|
|
1622
|
+
catch { }
|
|
1623
|
+
const status = (settleResp?.status || settleResp?.paymentIntent?.status || 'completed').toString().toLowerCase();
|
|
1624
|
+
const amountStr = (settleResp?.paymentIntent?.amount && String(settleResp.paymentIntent.amount)) ||
|
|
1625
|
+
(typeof usdAmount === 'number' ? String(usdAmount) : request?.amount?.toString?.() || '0');
|
|
1626
|
+
const out = {
|
|
1627
|
+
transactionId: Number(settleResp?.canisterTxId || 0),
|
|
1628
|
+
status: status === 'succeeded' ? 'completed' : status,
|
|
1629
|
+
amount: amountStr,
|
|
1630
|
+
recipientCanister: ledgerCanisterId,
|
|
1631
|
+
timestamp: new Date(),
|
|
1632
|
+
metadata: { ...(request.metadata || {}), x402: true },
|
|
1633
|
+
payment: settleResp || null,
|
|
1634
|
+
};
|
|
1635
|
+
// If x402 failed due to minimal limits, emit failure and fall back to normal flow
|
|
1636
|
+
const failMsg = settleResp?.message || settleResp?.error || '';
|
|
1637
|
+
if (out.status === 'failed' && (failMsg === 'x402_minimal_platform_fee_not_met' || failMsg === 'x402_minimum_amount_not_met')) {
|
|
1638
|
+
try {
|
|
1639
|
+
this.emit('icpay-sdk-transaction-failed', { ...out, reason: failMsg });
|
|
1640
|
+
}
|
|
1641
|
+
catch { }
|
|
1642
|
+
// Initiate regular flow (non-x402) with the same request
|
|
1643
|
+
const fallback = await this.createPaymentUsd(request);
|
|
1644
|
+
this.emitMethodSuccess('createPaymentX402Usd', fallback);
|
|
1645
|
+
return fallback;
|
|
1646
|
+
}
|
|
1647
|
+
const isTerminal = (() => {
|
|
1648
|
+
const s = String(out.status || '').toLowerCase();
|
|
1649
|
+
return s === 'completed' || s === 'succeeded' || s === 'failed' || s === 'canceled' || s === 'cancelled' || s === 'mismatched';
|
|
1650
|
+
})();
|
|
1651
|
+
if (isTerminal) {
|
|
1652
|
+
if (out.status === 'completed') {
|
|
1653
|
+
this.emit('icpay-sdk-transaction-completed', out);
|
|
1654
|
+
}
|
|
1655
|
+
else if (out.status === 'failed') {
|
|
1656
|
+
this.emit('icpay-sdk-transaction-failed', out);
|
|
1657
|
+
}
|
|
1658
|
+
else {
|
|
1659
|
+
this.emit('icpay-sdk-transaction-updated', out);
|
|
1660
|
+
}
|
|
1661
|
+
this.emitMethodSuccess('createPaymentX402Usd', out);
|
|
1662
|
+
return out;
|
|
1663
|
+
}
|
|
1664
|
+
// Non-terminal (e.g., requires_payment). Continue notifying until terminal.
|
|
1665
|
+
try {
|
|
1666
|
+
this.emit('icpay-sdk-transaction-updated', out);
|
|
1667
|
+
}
|
|
1668
|
+
catch { }
|
|
1669
|
+
const waited = await this.awaitIntentTerminal({
|
|
1670
|
+
paymentIntentId,
|
|
1671
|
+
ledgerCanisterId: ledgerCanisterId,
|
|
1672
|
+
amount: amountStr,
|
|
1673
|
+
metadata: { ...(request.metadata || {}), x402: true },
|
|
1674
|
+
});
|
|
1675
|
+
this.emitMethodSuccess('createPaymentX402Usd', waited);
|
|
1676
|
+
return waited;
|
|
1677
|
+
}
|
|
1678
|
+
catch {
|
|
1679
|
+
// Fall through to notify-based wait if settle endpoint not available
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
// Fallback: wait until terminal via notify loop
|
|
1683
|
+
const amountStr = (data?.paymentIntent?.amount && String(data.paymentIntent.amount)) ||
|
|
1684
|
+
(Array.isArray(data?.accepts) && data.accepts[0]?.maxAmountRequired && String(data.accepts[0].maxAmountRequired)) ||
|
|
1685
|
+
(typeof usdAmount === 'number' ? String(usdAmount) : request?.amount?.toString?.() || '0');
|
|
1686
|
+
const finalResponse = await this.awaitIntentTerminal({
|
|
1687
|
+
paymentIntentId,
|
|
1688
|
+
ledgerCanisterId: ledgerCanisterId,
|
|
1689
|
+
amount: amountStr,
|
|
1690
|
+
metadata: { ...(request.metadata || {}), x402: true },
|
|
1691
|
+
});
|
|
1692
|
+
this.emitMethodSuccess('createPaymentX402Usd', finalResponse);
|
|
1693
|
+
return finalResponse;
|
|
1694
|
+
}
|
|
1695
|
+
// No intent id provided: return a pending response with x402 metadata
|
|
1696
|
+
const pending = {
|
|
1697
|
+
transactionId: 0,
|
|
1698
|
+
status: 'pending',
|
|
1699
|
+
amount: (typeof usdAmount === 'number' ? String(usdAmount) : request?.amount?.toString?.() || '0'),
|
|
1700
|
+
recipientCanister: ledgerCanisterId || null,
|
|
1701
|
+
timestamp: new Date(),
|
|
1702
|
+
metadata: { ...(request.metadata || {}), x402: true },
|
|
1703
|
+
payment: null,
|
|
1704
|
+
};
|
|
1705
|
+
this.emitMethodSuccess('createPaymentX402Usd', pending);
|
|
1706
|
+
return pending;
|
|
1707
|
+
}
|
|
1708
|
+
// Any other error: rethrow to allow caller fallback
|
|
1709
|
+
throw e;
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
catch (error) {
|
|
1713
|
+
if (error instanceof errors_1.IcpayError) {
|
|
1714
|
+
this.emitMethodError('createPaymentX402Usd', error);
|
|
1715
|
+
throw error;
|
|
1716
|
+
}
|
|
1717
|
+
const err = new errors_1.IcpayError({
|
|
1718
|
+
code: errors_1.ICPAY_ERROR_CODES.API_ERROR,
|
|
1719
|
+
message: 'X402 payment flow not available',
|
|
1720
|
+
details: error,
|
|
1721
|
+
});
|
|
1722
|
+
this.emitMethodError('createPaymentX402Usd', err);
|
|
1723
|
+
throw err;
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1266
1726
|
/**
|
|
1267
1727
|
* Continuously notifies the API about a payment intent (no canister tx id) for Onramp.
|
|
1268
1728
|
* Uses publishable-key public endpoint. Emits icpay-sdk-transaction-updated only when
|
|
@@ -1319,6 +1779,8 @@ class Icpay {
|
|
|
1319
1779
|
const body = { paymentIntentId: params.paymentIntentId };
|
|
1320
1780
|
if (params.canisterTransactionId)
|
|
1321
1781
|
body.canisterTxId = params.canisterTransactionId;
|
|
1782
|
+
if (params.transactionId)
|
|
1783
|
+
body.transactionId = params.transactionId;
|
|
1322
1784
|
if (params.orderId)
|
|
1323
1785
|
body.orderId = params.orderId;
|
|
1324
1786
|
const resp = await notifyClient.post(notifyPath, body);
|
|
@@ -1364,6 +1826,7 @@ class Icpay {
|
|
|
1364
1826
|
const resp = await this.performNotifyPaymentIntent({
|
|
1365
1827
|
paymentIntentId: params.paymentIntentId,
|
|
1366
1828
|
canisterTransactionId: params.canisterTransactionId,
|
|
1829
|
+
transactionId: params.transactionId || (params?.metadata?.evmTxHash ? String(params.metadata.evmTxHash) : undefined),
|
|
1367
1830
|
maxAttempts: 1,
|
|
1368
1831
|
delayMs: 0,
|
|
1369
1832
|
});
|
|
@@ -1426,6 +1889,8 @@ class Icpay {
|
|
|
1426
1889
|
id: ledger.id,
|
|
1427
1890
|
name: ledger.name,
|
|
1428
1891
|
symbol: ledger.symbol,
|
|
1892
|
+
chainId: ledger.chainId,
|
|
1893
|
+
shortcode: ledger.shortcode ?? null,
|
|
1429
1894
|
canisterId: ledger.canisterId,
|
|
1430
1895
|
standard: ledger.standard,
|
|
1431
1896
|
decimals: ledger.decimals,
|