@pioneer-platform/pioneer-sdk 8.12.0 → 8.12.4

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.
@@ -104,6 +104,14 @@ async function fetchTokenPriceInUsd(pioneer: any, caip: string) {
104
104
  }
105
105
 
106
106
  // Create an unsigned EVM transaction
107
+ /**
108
+ * Create an unsigned EVM transaction
109
+ *
110
+ * @param feeLevel - Fee speed preference:
111
+ * - 1-2: Slow (80% of base gas price)
112
+ * - 3-7: Average (100% of base gas price) [DEFAULT: 5]
113
+ * - 8-10: Fast (150% of base gas price)
114
+ */
107
115
  export async function createUnsignedEvmTx(
108
116
  caip,
109
117
  to,
@@ -113,7 +121,7 @@ export async function createUnsignedEvmTx(
113
121
  pioneer,
114
122
  pubkeyContext,
115
123
  isMax,
116
- feeLevel = 5, // Added feeLevel parameter with default of 5 (average)
124
+ feeLevel = 5, // Default: average fee level (100% of base)
117
125
  ) {
118
126
  const tag = TAG + ' | createUnsignedEvmTx | ';
119
127
 
@@ -281,18 +289,20 @@ export async function createUnsignedEvmTx(
281
289
 
282
290
  if (memo === ' ') memo = '';
283
291
 
292
+ // Check if this is a THORChain swap (memo starts with '=' or 'SWAP' or contains ':')
293
+ // Define this before the switch so it's available in all cases
294
+ const isThorchainSwap =
295
+ memo && (memo.startsWith('=') || memo.startsWith('SWAP') || memo.includes(':'));
296
+
284
297
  // Build transaction object based on asset type
285
298
  switch (assetType) {
286
299
  case 'gas': {
287
- // Check if this is a THORChain swap (needs more gas for contract call)
288
- const isThorchainOperation =
289
- memo && (memo.startsWith('=') || memo.startsWith('SWAP') || memo.includes(':'));
290
-
300
+ // Use the top-level isThorchainSwap check
291
301
  let gasLimit;
292
- if (isThorchainOperation) {
293
- // THORChain depositWithExpiry requires more gas (90-120k typical)
294
- // Use 120000 to be safe for all network conditions
295
- gasLimit = BigInt(120000);
302
+ if (isThorchainSwap) {
303
+ // THORChain depositWithExpiry requires more gas (90-100k typical)
304
+ // Use 100000 with 10% buffer for safety (reduced from 120000 to minimize gas fees)
305
+ gasLimit = BigInt(100000);
296
306
  console.log(tag, 'Using higher gas limit for THORChain swap:', gasLimit.toString());
297
307
  } else {
298
308
  // Standard gas limit for ETH transfer
@@ -300,7 +310,7 @@ export async function createUnsignedEvmTx(
300
310
  gasLimit = chainId === 1 ? BigInt(21000) : BigInt(25000);
301
311
  }
302
312
 
303
- if (memo && memo !== '' && !isThorchainOperation) {
313
+ if (memo && memo !== '' && !isThorchainSwap) {
304
314
  const memoBytes = Buffer.from(memo, 'utf8').length;
305
315
  gasLimit += BigInt(memoBytes) * 68n; // Approximate additional gas
306
316
  //console.log(tag, 'Adjusted gasLimit for memo:', gasLimit.toString());
@@ -321,17 +331,37 @@ export async function createUnsignedEvmTx(
321
331
  console.log(tag, 'isMax calculation - balance:', balance.toString(), 'gasFee:', gasFee.toString(), 'buffer:', buffer.toString(), 'amountWei:', amountWei.toString());
322
332
  } else {
323
333
  amountWei = BigInt(Math.round(amount * 1e18));
324
- if (amountWei + gasFee > balance) {
325
- throw new Error('Insufficient funds for the transaction amount and gas fees');
334
+ const totalNeeded = amountWei + gasFee;
335
+
336
+ if (totalNeeded > balance) {
337
+ const availableForSwap = balance > gasFee ? balance - gasFee : 0n;
338
+ const balanceEth = (Number(balance) / 1e18).toFixed(6);
339
+ const amountEth = (Number(amountWei) / 1e18).toFixed(6);
340
+ const gasFeeEth = (Number(gasFee) / 1e18).toFixed(6);
341
+ const availableEth = (Number(availableForSwap) / 1e18).toFixed(6);
342
+ const swapType = isThorchainSwap ? 'THORChain swap' : 'transfer';
343
+
344
+ throw new Error(
345
+ `Insufficient funds for the transaction amount and gas fees.\n` +
346
+ `Balance: ${balanceEth} ETH\n` +
347
+ `Attempting to ${swapType}: ${amountEth} ETH\n` +
348
+ `Estimated gas fee: ${gasFeeEth} ETH (${gasLimit.toString()} gas limit)\n` +
349
+ `Total needed: ${(Number(totalNeeded) / 1e18).toFixed(6)} ETH\n` +
350
+ `Maximum swappable: ${availableEth} ETH (after gas fees)`
351
+ );
326
352
  }
327
353
  }
328
354
 
329
- //console.log(tag, 'amountWei:', amountWei.toString());
330
-
331
- // Check if this is a THORChain swap (memo starts with '=' or 'SWAP' or contains ':')
332
- const isThorchainSwap =
333
- memo && (memo.startsWith('=') || memo.startsWith('SWAP') || memo.includes(':'));
355
+ console.log(tag, 'Transaction calculation:', {
356
+ balance: balance.toString() + ' wei (' + (Number(balance) / 1e18).toFixed(6) + ' ETH)',
357
+ amountWei: amountWei.toString() + ' wei (' + (Number(amountWei) / 1e18).toFixed(6) + ' ETH)',
358
+ gasFee: gasFee.toString() + ' wei (' + (Number(gasFee) / 1e18).toFixed(6) + ' ETH)',
359
+ gasLimit: gasLimit.toString(),
360
+ gasPrice: gasPrice.toString() + ' wei (' + (Number(gasPrice) / 1e9).toFixed(2) + ' gwei)',
361
+ isThorchainSwap: isThorchainSwap,
362
+ });
334
363
 
364
+ // Use the top-level isThorchainSwap check (defined before switch statement)
335
365
  let txData = '0x';
336
366
 
337
367
  if (isThorchainSwap) {
@@ -366,19 +396,35 @@ export async function createUnsignedEvmTx(
366
396
  const inboundResponse = await fetch('https://thornode.ninerealms.com/thorchain/inbound_addresses');
367
397
  if (inboundResponse.ok) {
368
398
  const inboundData = await inboundResponse.json();
369
- // Find ETH inbound data
370
- const ethInbound = inboundData.find(inbound =>
371
- inbound.chain === 'ETH' && !inbound.halted
399
+
400
+ // Map chainId to THORChain chain name
401
+ const chainIdToThorchain: Record<number, string> = {
402
+ 1: 'ETH', // Ethereum mainnet
403
+ 43114: 'AVAX', // Avalanche
404
+ 8453: 'BASE', // Base
405
+ 56: 'BSC', // Binance Smart Chain
406
+ };
407
+
408
+ const thorchainName = chainIdToThorchain[chainId];
409
+ if (!thorchainName) {
410
+ throw new Error(`Unsupported chain ID for THORChain swap: ${chainId}`);
411
+ }
412
+
413
+ console.log(tag, 'Looking for THORChain inbound for chain:', thorchainName, 'chainId:', chainId);
414
+
415
+ // Find inbound data for the specific chain
416
+ const chainInbound = inboundData.find(inbound =>
417
+ inbound.chain === thorchainName && !inbound.halted
372
418
  );
373
- if (ethInbound) {
374
- vaultAddress = ethInbound.address; // This is the Asgard vault
375
- routerAddress = ethInbound.router || to; // Use fetched router or fallback to 'to'
419
+ if (chainInbound) {
420
+ vaultAddress = chainInbound.address; // This is the Asgard vault
421
+ routerAddress = chainInbound.router || to; // Use fetched router or fallback to 'to'
376
422
  console.log(tag, 'Using THORChain inbound addresses - vault:', vaultAddress, 'router:', routerAddress);
377
-
423
+
378
424
  // Update the 'to' address to be the router (in case it wasn't)
379
425
  to = routerAddress;
380
426
  } else {
381
- throw new Error('ETH inbound is halted or not found - cannot proceed with swap');
427
+ throw new Error(`${thorchainName} inbound is halted or not found - cannot proceed with swap`);
382
428
  }
383
429
  }
384
430
  } catch (fetchError) {
@@ -392,6 +438,21 @@ export async function createUnsignedEvmTx(
392
438
  throw new Error('Cannot proceed with THORChain swap - vault address is invalid (0x0)');
393
439
  }
394
440
 
441
+ // CRITICAL SAFETY CHECK: Ensure we're sending to the router, not the vault
442
+ // This prevents the original bug where USDC was sent to a user wallet
443
+ if (to.toLowerCase() === vaultAddress.toLowerCase()) {
444
+ console.warn(tag, '⚠️ WARNING: "to" address equals vault address - this should be the router!');
445
+ console.warn(tag, '⚠️ Using fetched router address instead:', routerAddress);
446
+ to = routerAddress; // Override with correct router
447
+ }
448
+
449
+ // Validate router address format
450
+ if (!/^0x[a-fA-F0-9]{40}$/.test(to)) {
451
+ throw new Error(`Invalid router address format: ${to}`);
452
+ }
453
+
454
+ console.log(tag, '✅ Final validation passed - router:', to, 'vault:', vaultAddress);
455
+
395
456
  // Use depositWithExpiry for better safety
396
457
  // Function signature: depositWithExpiry(address,address,uint256,string,uint256)
397
458
  // Function selector: 0x44bc937b
@@ -539,10 +600,143 @@ export async function createUnsignedEvmTx(
539
600
  // For simplicity, we assume user has enough tokens
540
601
  // In practice, need to check token balance
541
602
 
542
- const data = encodeTransferData(to, amountWei);
603
+ let data: string;
604
+ let finalTo: string;
605
+
606
+ // Check if this is a THORChain swap
607
+ if (isThorchainSwap) {
608
+ console.log(tag, '🔄 ERC20 THORChain swap detected - encoding depositWithExpiry');
609
+
610
+ // For ERC20 THORChain swaps, we need to:
611
+ // 1. Approve the router to spend tokens (done separately by user)
612
+ // 2. Call router.depositWithExpiry(vault, asset, amount, memo, expiry)
613
+
614
+ // Fetch router and vault addresses from THORChain (same as native ETH flow)
615
+ let vaultAddress = '0x0000000000000000000000000000000000000000';
616
+ let routerAddress = to; // The 'to' field should already be the router
617
+
618
+ try {
619
+ // Fetch inbound addresses from THORChain
620
+ const inboundResponse = await fetch('https://thornode.ninerealms.com/thorchain/inbound_addresses');
621
+ if (inboundResponse.ok) {
622
+ const inboundData = await inboundResponse.json();
623
+
624
+ // Map chainId to THORChain chain name
625
+ const chainIdToThorchain: Record<number, string> = {
626
+ 1: 'ETH', // Ethereum mainnet
627
+ 43114: 'AVAX', // Avalanche
628
+ 8453: 'BASE', // Base
629
+ 56: 'BSC', // Binance Smart Chain
630
+ };
631
+
632
+ const thorchainName = chainIdToThorchain[chainId];
633
+ if (!thorchainName) {
634
+ throw new Error(`Unsupported chain ID for THORChain swap: ${chainId}`);
635
+ }
636
+
637
+ console.log(tag, 'Looking for THORChain inbound for chain:', thorchainName, 'chainId:', chainId);
638
+
639
+ // Find inbound data for the specific chain
640
+ const chainInbound = inboundData.find(inbound =>
641
+ inbound.chain === thorchainName && !inbound.halted
642
+ );
643
+ if (chainInbound) {
644
+ vaultAddress = chainInbound.address; // This is the Asgard vault
645
+ routerAddress = chainInbound.router || to; // Use fetched router or fallback to 'to'
646
+ console.log(tag, 'Using THORChain inbound addresses - vault:', vaultAddress, 'router:', routerAddress);
647
+
648
+ // Update the 'to' address to be the router (in case it wasn't)
649
+ to = routerAddress;
650
+ } else {
651
+ throw new Error(`${thorchainName} inbound is halted or not found - cannot proceed with swap`);
652
+ }
653
+ }
654
+ } catch (fetchError) {
655
+ console.error(tag, 'Failed to fetch inbound addresses:', fetchError);
656
+ // ABORT - cannot proceed without proper vault address
657
+ throw new Error(`Cannot proceed with THORChain swap - failed to fetch inbound addresses: ${fetchError.message}`);
658
+ }
659
+
660
+ // Final validation - never use 0x0 as vault
661
+ if (vaultAddress === '0x0000000000000000000000000000000000000000') {
662
+ throw new Error('Cannot proceed with THORChain swap - vault address is invalid (0x0)');
663
+ }
664
+
665
+ // CRITICAL SAFETY CHECK: Ensure we're sending to the router, not the vault
666
+ if (to.toLowerCase() === vaultAddress.toLowerCase()) {
667
+ console.warn(tag, '⚠️ WARNING: "to" address equals vault address - this should be the router!');
668
+ console.warn(tag, '⚠️ Using fetched router address instead:', routerAddress);
669
+ to = routerAddress; // Override with correct router
670
+ }
671
+
672
+ // Validate router address format
673
+ if (!/^0x[a-fA-F0-9]{40}$/.test(to)) {
674
+ throw new Error(`Invalid router address format: ${to}`);
675
+ }
676
+
677
+ console.log(tag, '✅ Final validation passed - router:', to, 'vault:', vaultAddress);
678
+ console.log(tag, '✅ ERC20 THORChain swap addresses validated');
679
+ console.log(tag, ' Router:', to);
680
+ console.log(tag, ' Vault:', vaultAddress);
681
+ console.log(tag, ' Token:', contractAddress);
682
+
683
+ // Encode depositWithExpiry for ERC20 tokens
684
+ // Function signature: depositWithExpiry(address,address,uint256,string,uint256)
685
+ const functionSelector = '44bc937b';
686
+
687
+ // Calculate expiry time (current time + 1 hour)
688
+ const expiryTime = Math.floor(Date.now() / 1000) + 3600;
689
+
690
+ // Encode parameters
691
+ const vaultPadded = vaultAddress.toLowerCase().replace(/^0x/, '').padStart(64, '0');
692
+ const assetPadded = contractAddress.toLowerCase().replace(/^0x/, '').padStart(64, '0'); // ERC20 token address
693
+ const amountPadded = amountWei.toString(16).padStart(64, '0');
694
+
695
+ // String offset for depositWithExpiry with 5 parameters
696
+ // Offset must point after all 5 head words: 5 * 32 = 160 = 0xa0
697
+ const stringOffset = (5 * 32).toString(16).padStart(64, '0');
698
+
699
+ const expiryPadded = expiryTime.toString(16).padStart(64, '0');
700
+
701
+ // Encode memo
702
+ const fixedMemo = memo || '';
703
+ const memoBytes = Buffer.from(fixedMemo, 'utf8');
704
+ const memoHex = memoBytes.toString('hex');
705
+ const stringLength = memoBytes.length.toString(16).padStart(64, '0');
706
+
707
+ // Pad memo to 32-byte boundary
708
+ const paddingLength = (32 - (memoBytes.length % 32)) % 32;
709
+ const memoPadded = memoHex + '0'.repeat(paddingLength * 2);
710
+
711
+ // Construct transaction data
712
+ data = '0x' + functionSelector + vaultPadded + assetPadded + amountPadded + stringOffset + expiryPadded + stringLength + memoPadded;
713
+
714
+ // Set recipient to router (NOT token contract)
715
+ finalTo = to; // 'to' has been validated and updated to be the router
716
+
717
+ console.log(tag, '✅ Encoded ERC20 depositWithExpiry:', {
718
+ functionSelector: '0x' + functionSelector,
719
+ vault: vaultAddress,
720
+ asset: contractAddress,
721
+ amount: amountWei.toString(),
722
+ memo: fixedMemo,
723
+ expiry: expiryTime,
724
+ dataLength: data.length,
725
+ });
726
+
727
+ // Increase gas limit for router call (more complex than simple transfer)
728
+ // ERC20 depositWithExpiry typically uses 150-200k gas
729
+ gasLimit = BigInt(200000); // Reduced from 300k - router calls with 33% buffer
730
+ } else {
731
+ // Regular ERC20 transfer (non-THORChain)
732
+ data = encodeTransferData(to, amountWei);
733
+ finalTo = contractAddress;
734
+ }
543
735
 
544
736
  const ethPriceInUsd = await fetchEthPriceInUsd(pioneer, networkId);
545
- const gasFeeUsd = (Number(gasFee) / 1e18) * ethPriceInUsd;
737
+ // Recalculate gasFee in case gasLimit was updated for THORChain swap
738
+ const finalGasFee = gasPrice * gasLimit;
739
+ const gasFeeUsd = (Number(finalGasFee) / 1e18) * ethPriceInUsd;
546
740
 
547
741
  // For token price, fetch from Pioneer API using the full CAIP
548
742
  const tokenPriceInUsd = await fetchTokenPriceInUsd(pioneer, caip);
@@ -551,10 +745,11 @@ export async function createUnsignedEvmTx(
551
745
 
552
746
  unsignedTx = {
553
747
  chainId,
748
+ from: address, // Required for simulation
554
749
  nonce: toHex(nonce),
555
750
  gas: toHex(gasLimit),
556
751
  gasPrice: toHex(gasPrice),
557
- to: contractAddress,
752
+ to: finalTo,
558
753
  value: '0x0',
559
754
  data,
560
755
  // USD estimations