@ic-pay/icpay-sdk 1.3.96 → 1.4.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/dist/index.d.ts +47 -3
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +790 -325
  4. package/dist/index.js.map +1 -1
  5. package/dist/protected.d.ts.map +1 -1
  6. package/dist/protected.js.map +1 -1
  7. package/dist/types/index.d.ts +48 -2
  8. package/dist/types/index.d.ts.map +1 -1
  9. package/dist/x402/builders.d.ts +60 -0
  10. package/dist/x402/builders.d.ts.map +1 -0
  11. package/dist/x402/builders.js +210 -0
  12. package/dist/x402/builders.js.map +1 -0
  13. package/dist/x402/common.d.ts +23 -0
  14. package/dist/x402/common.d.ts.map +1 -0
  15. package/dist/x402/common.js +108 -0
  16. package/dist/x402/common.js.map +1 -0
  17. package/dist/x402/encode.d.ts +23 -0
  18. package/dist/x402/encode.d.ts.map +1 -0
  19. package/dist/x402/encode.js +71 -0
  20. package/dist/x402/encode.js.map +1 -0
  21. package/dist/x402/facilitator.d.ts +88 -0
  22. package/dist/x402/facilitator.d.ts.map +1 -0
  23. package/dist/x402/facilitator.js +214 -0
  24. package/dist/x402/facilitator.js.map +1 -0
  25. package/dist/x402/fetchWithPayment.d.ts +43 -0
  26. package/dist/x402/fetchWithPayment.d.ts.map +1 -0
  27. package/dist/x402/fetchWithPayment.js +117 -0
  28. package/dist/x402/fetchWithPayment.js.map +1 -0
  29. package/dist/x402/schemas.d.ts +34 -0
  30. package/dist/x402/schemas.d.ts.map +1 -0
  31. package/dist/x402/schemas.js +126 -0
  32. package/dist/x402/schemas.js.map +1 -0
  33. package/dist/x402/settle-payment.d.ts +120 -0
  34. package/dist/x402/settle-payment.d.ts.map +1 -0
  35. package/dist/x402/settle-payment.js +177 -0
  36. package/dist/x402/settle-payment.js.map +1 -0
  37. package/dist/x402/sign.d.ts +13 -0
  38. package/dist/x402/sign.d.ts.map +1 -0
  39. package/dist/x402/sign.js +221 -0
  40. package/dist/x402/sign.js.map +1 -0
  41. package/dist/x402/types.d.ts +58 -0
  42. package/dist/x402/types.d.ts.map +1 -0
  43. package/dist/x402/types.js +3 -0
  44. package/dist/x402/types.js.map +1 -0
  45. package/dist/x402/verify-payment.d.ts +70 -0
  46. package/dist/x402/verify-payment.d.ts.map +1 -0
  47. package/dist/x402/verify-payment.js +123 -0
  48. package/dist/x402/verify-payment.js.map +1 -0
  49. package/package.json +9 -9
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
- if (!ledgerCanisterId && request.symbol) {
430
- ledgerCanisterId = await this.getLedgerCanisterIdBySymbol(request.symbol);
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: 'Either ledgerCanisterId or symbol must be provided',
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
- // Get the expected sender principal from connected wallet
602
- const expectedSenderPrincipal = this.connectedWallet?.owner || this.connectedWallet?.principal?.toString();
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
- symbol: request.symbol,
615
- ledgerCanisterId,
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 if possible
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
- let transferResult;
663
- try {
664
- // ICP Ledger: use ICRC-1 transfer (ICP ledger supports ICRC-1)
665
- (0, utils_1.debugLog)(this.config.debug || false, 'sending ICRC-1 transfer (ICP)');
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: amount.toString(),
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 balance for all verified ledgers for the connected wallet (public method)
1242
+ * Public: Get balances for an external wallet (IC principal or EVM address) using publishable key
1008
1243
  */
1009
- async getAllLedgerBalances() {
1010
- this.emitMethodStart('getAllLedgerBalances');
1244
+ async getExternalWalletBalances(params) {
1245
+ this.emitMethodStart('getExternalWalletBalances', { params });
1011
1246
  try {
1012
- if (!this.isWalletConnected()) {
1013
- throw new errors_1.IcpayError({
1014
- code: 'WALLET_NOT_CONNECTED',
1015
- message: 'Wallet must be connected to fetch balances'
1016
- });
1017
- }
1018
- const verifiedLedgers = await this.getVerifiedLedgers();
1019
- const balances = [];
1020
- let totalBalancesUSD = 0;
1021
- for (const ledger of verifiedLedgers) {
1022
- try {
1023
- const rawBalance = await this.getLedgerBalance(ledger.canisterId);
1024
- const formattedBalance = this.formatBalance(rawBalance.toString(), ledger.decimals);
1025
- const balance = {
1026
- ledgerId: ledger.id,
1027
- ledgerName: ledger.name,
1028
- ledgerSymbol: ledger.symbol,
1029
- canisterId: ledger.canisterId,
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
- totalBalancesUSD: totalBalancesUSD > 0 ? totalBalancesUSD : undefined,
1057
- lastUpdated: new Date()
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('getAllLedgerBalances', { count: balances.length, totalUSD: result.totalBalancesUSD });
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: 'BALANCES_FETCH_FAILED',
1065
- message: 'Failed to fetch all ledger balances',
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('getAllLedgerBalances', err);
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 ledger = await this.publicApiClient.get(`/sdk/public/ledgers/${ledgerCanisterId}`);
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
- // Resolve ledgerCanisterId from symbol if needed
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: 'Either ledgerCanisterId or symbol must be provided',
1484
+ message: 'Provide either tokenShortcode or ledgerCanisterId (symbol is deprecated).',
1234
1485
  details: { request }
1235
1486
  });
1236
- this.emitMethodError('createPaymentUsd', err);
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,