From 48e5e939e12bc93d3ca1eaed9aa050239635d497 Mon Sep 17 00:00:00 2001 From: kevin <35275952+kaladinlight@users.noreply.github.com> Date: Fri, 25 Oct 2024 08:45:57 -0600 Subject: [PATCH] feat: solana spl token support (#7995) --- .env.develop | 1 + packages/chain-adapters/package.json | 1 + .../src/solana/SolanaChainAdapter.ts | 138 ++++++++++-- packages/chain-adapters/src/solana/types.ts | 2 + packages/unchained-client/package.json | 2 + .../__tests__/mockData/tokenSelfSend.ts | 82 +++++++ .../__tests__/mockData/tokenStandard.ts | 107 +++++++++ .../mockData/tokenStandardWithCreate.ts | 173 +++++++++++++++ .../parser/__tests__/mockData/tokens.ts | 17 ++ .../solana/parser/__tests__/solana.test.ts | 205 +++++++++++++++++- .../src/solana/parser/index.ts | 79 +++++-- src/components/AssetHeader/AssetActions.tsx | 3 +- src/components/Modals/Send/utils.ts | 19 +- .../useGetTradeQuotes/getTradeQuoteInput.ts | 20 +- .../TradeAssetSearch/TradeAssetSearch.tsx | 16 +- .../components/SearchTermAssetList.tsx | 9 +- src/lib/utils/index.ts | 4 +- .../slices/portfolioSlice/portfolioSlice.ts | 150 +------------ .../slices/portfolioSlice/utils/index.ts | 152 ++++++++++++- yarn.lock | 178 ++++++++++++++- 20 files changed, 1148 insertions(+), 210 deletions(-) create mode 100644 packages/unchained-client/src/solana/parser/__tests__/mockData/tokenSelfSend.ts create mode 100644 packages/unchained-client/src/solana/parser/__tests__/mockData/tokenStandard.ts create mode 100644 packages/unchained-client/src/solana/parser/__tests__/mockData/tokenStandardWithCreate.ts create mode 100644 packages/unchained-client/src/solana/parser/__tests__/mockData/tokens.ts diff --git a/.env.develop b/.env.develop index d35add53a7a..fca8c49b9b5 100644 --- a/.env.develop +++ b/.env.develop @@ -1,4 +1,5 @@ # feature flags +REACT_APP_FEATURE_SOLANA=true REACT_APP_FEATURE_LIMIT_ORDERS=true REACT_APP_FEATURE_PUBLIC_TRADE_ROUTE=true REACT_APP_FEATURE_PHANTOM_WALLET=true diff --git a/packages/chain-adapters/package.json b/packages/chain-adapters/package.json index 65e5a6d6e43..39841b00b0d 100644 --- a/packages/chain-adapters/package.json +++ b/packages/chain-adapters/package.json @@ -21,6 +21,7 @@ "@shapeshiftoss/types": "workspace:^", "@shapeshiftoss/unchained-client": "workspace:^", "@shapeshiftoss/utils": "workspace:^", + "@solana/spl-token": "^0.4.9", "@solana/web3.js": "^1.95.3", "bech32": "^2.0.0", "coinselect": "^3.1.13", diff --git a/packages/chain-adapters/src/solana/SolanaChainAdapter.ts b/packages/chain-adapters/src/solana/SolanaChainAdapter.ts index 52d208e2984..673daa500ab 100644 --- a/packages/chain-adapters/src/solana/SolanaChainAdapter.ts +++ b/packages/chain-adapters/src/solana/SolanaChainAdapter.ts @@ -6,12 +6,21 @@ import { solAssetId, toAssetId, } from '@shapeshiftoss/caip' -import type { SolanaSignTx } from '@shapeshiftoss/hdwallet-core' +import type { SolanaSignTx, SolanaTxInstruction } from '@shapeshiftoss/hdwallet-core' import { supportsSolana } from '@shapeshiftoss/hdwallet-core' import type { BIP44Params } from '@shapeshiftoss/types' import { KnownChainIds } from '@shapeshiftoss/types' import * as unchained from '@shapeshiftoss/unchained-client' import { BigNumber, bn } from '@shapeshiftoss/utils' +import { + createAssociatedTokenAccountInstruction, + createTransferInstruction, + getAccount, + getAssociatedTokenAddressSync, + TokenAccountNotFoundError, + TokenInvalidAccountOwnerError, +} from '@solana/spl-token' +import type { TransactionInstruction } from '@solana/web3.js' import { ComputeBudgetProgram, Connection, @@ -84,6 +93,7 @@ export class ChainAdapter implements IChainAdapter this.parser = new unchained.solana.TransactionParser({ assetId: this.assetId, chainId: this.chainId, + api: this.providers.http, }) } @@ -197,8 +207,10 @@ export class ChainAdapter implements IChainAdapter txToSign: SignTx }> { try { - const { accountNumber, to, value, chainSpecific } = input + const { accountNumber, to, chainSpecific, value } = input + const { instructions = [], tokenId } = chainSpecific + const from = await this.getAddress(input) const { blockhash } = await this.connection.getLatestBlockhash() const computeUnitLimit = chainSpecific.computeUnitLimit @@ -209,15 +221,27 @@ export class ChainAdapter implements IChainAdapter ? Number(chainSpecific.computeUnitPrice) : undefined + if (tokenId) { + const tokenTransferInstructions = await this.buildTokenTransferInstructions({ + from, + to, + tokenId, + value, + }) + + instructions.push( + ...tokenTransferInstructions.map(instruction => this.convertInstruction(instruction)), + ) + } + const txToSign: SignTx = { addressNList: toAddressNList(this.getBIP44Params({ accountNumber })), blockHash: blockhash, computeUnitLimit, computeUnitPrice, - // TODO: handle extra instructions - instructions: undefined, - to, - value, + instructions, + to: tokenId ? '' : to, + value: tokenId ? '' : value, } return { txToSign } @@ -289,7 +313,7 @@ export class ChainAdapter implements IChainAdapter ): Promise> { const { baseFee, fast, average, slow } = await this.providers.http.getPriorityFees() - const serializedTx = this.buildEstimationSerializedTx(input) + const serializedTx = await this.buildEstimationSerializedTx(input) const computeUnits = await this.providers.http.estimateFees({ estimateFeesBody: { serializedTx }, }) @@ -362,18 +386,36 @@ export class ChainAdapter implements IChainAdapter this.providers.ws.close('txs') } - private buildEstimationSerializedTx(input: GetFeeDataInput): string { - const instructions = input.chainSpecific.instructions ?? [] + private async buildEstimationSerializedTx( + input: GetFeeDataInput, + ): Promise { + const { to, chainSpecific } = input + const { from, tokenId, instructions = [] } = chainSpecific + + if (!to) throw new Error(`${this.getName()}ChainAdapter: to is required`) + if (!input.value) throw new Error(`${this.getName()}ChainAdapter: value is required`) const value = Number(input.value) - if (!isNaN(value) && value > 0 && input.to) { - instructions.push( - SystemProgram.transfer({ - fromPubkey: new PublicKey(input.chainSpecific.from), - toPubkey: new PublicKey(input.to), - lamports: value, - }), - ) + + if (!isNaN(value) && value > 0) { + if (tokenId) { + const tokenTransferInstructions = await this.buildTokenTransferInstructions({ + from, + to, + tokenId, + value: input.value, + }) + + instructions.push(...tokenTransferInstructions) + } else { + instructions.push( + SystemProgram.transfer({ + fromPubkey: new PublicKey(from), + toPubkey: new PublicKey(to), + lamports: value, + }), + ) + } } // Set compute unit limit to the maximum compute units for the purposes of estimating the compute unit cost of a transaction, @@ -395,6 +437,68 @@ export class ChainAdapter implements IChainAdapter return Buffer.from(transaction.serialize()).toString('base64') } + private async buildTokenTransferInstructions({ + from, + to, + tokenId, + value, + }: { + from: string + to: string + tokenId: string + value: string + }): Promise { + const instructions: TransactionInstruction[] = [] + + const destinationTokenAccount = getAssociatedTokenAddressSync( + new PublicKey(tokenId), + new PublicKey(to), + ) + + // check if destination token account exists and add creation instruction if it doesn't + try { + await getAccount(this.connection, destinationTokenAccount) + } catch (err) { + if ( + err instanceof TokenAccountNotFoundError || + err instanceof TokenInvalidAccountOwnerError + ) { + instructions.push( + createAssociatedTokenAccountInstruction( + // sender pays for creation of the token account + new PublicKey(from), + destinationTokenAccount, + new PublicKey(to), + new PublicKey(tokenId), + ), + ) + } + } + + instructions.push( + createTransferInstruction( + getAssociatedTokenAddressSync(new PublicKey(tokenId), new PublicKey(from)), + destinationTokenAccount, + new PublicKey(from), + Number(value), + ), + ) + + return instructions + } + + private convertInstruction(instruction: TransactionInstruction): SolanaTxInstruction { + return { + keys: instruction.keys.map(key => ({ + pubkey: key.pubkey.toString(), + isSigner: key.isSigner, + isWritable: key.isWritable, + })), + programId: instruction.programId.toString(), + data: instruction.data, + } + } + private async parseTx(tx: unchained.solana.Tx, pubkey: string): Promise { const { address: _, ...parsedTx } = await this.parser.parse(tx, pubkey) diff --git a/packages/chain-adapters/src/solana/types.ts b/packages/chain-adapters/src/solana/types.ts index 50e53972e50..a898f36d669 100644 --- a/packages/chain-adapters/src/solana/types.ts +++ b/packages/chain-adapters/src/solana/types.ts @@ -23,11 +23,13 @@ export type BuildTransactionInput = { export type BuildTxInput = { computeUnitLimit?: string computeUnitPrice?: string + tokenId?: string instructions?: SolanaTxInstruction[] } export type GetFeeDataInput = { from: string + tokenId?: string instructions?: TransactionInstruction[] } diff --git a/packages/unchained-client/package.json b/packages/unchained-client/package.json index b1a0aa2e3f6..9c1aa0d1cb1 100644 --- a/packages/unchained-client/package.json +++ b/packages/unchained-client/package.json @@ -21,6 +21,8 @@ "@shapeshiftoss/caip": "workspace:^", "@shapeshiftoss/common-api": "^9.3.0", "@shapeshiftoss/contracts": "workspace:^", + "@solana/spl-token": "^0.4.9", + "@solana/web3.js": "^1.95.4", "isomorphic-ws": "^4.0.1", "ws": "^8.17.1" }, diff --git a/packages/unchained-client/src/solana/parser/__tests__/mockData/tokenSelfSend.ts b/packages/unchained-client/src/solana/parser/__tests__/mockData/tokenSelfSend.ts new file mode 100644 index 00000000000..6b982c5f141 --- /dev/null +++ b/packages/unchained-client/src/solana/parser/__tests__/mockData/tokenSelfSend.ts @@ -0,0 +1,82 @@ +import type { Tx } from '../../..' + +const tx: Tx = { + txid: '2GcoTakKNRuAwsNRc5RgZJNwvw4JNe73ciJUsopRGgDcY5s4632TVzAxXRmPUuvu2CmBksUnsuPCvZav2Uw1DsvS', + blockHeight: 297092056, + description: + 'DsYwEVzeSNMkU5PVwjwtZ8EDRQxaR6paXfFAdhMQxmaV transferred 0.5 USDC to DsYwEVzeSNMkU5PVwjwtZ8EDRQxaR6paXfFAdhMQxmaV.', + type: 'TRANSFER', + source: 'SOLANA_PROGRAM_LIBRARY', + fee: 5000, + feePayer: 'DsYwEVzeSNMkU5PVwjwtZ8EDRQxaR6paXfFAdhMQxmaV', + signature: + '2GcoTakKNRuAwsNRc5RgZJNwvw4JNe73ciJUsopRGgDcY5s4632TVzAxXRmPUuvu2CmBksUnsuPCvZav2Uw1DsvS', + slot: 297092056, + timestamp: 1729619880, + tokenTransfers: [ + { + fromTokenAccount: '8suMXgzzmbBpc4s12REVd6EbvmNhpEq7eAfEyj4DQSeH', + toTokenAccount: '8suMXgzzmbBpc4s12REVd6EbvmNhpEq7eAfEyj4DQSeH', + fromUserAccount: 'DsYwEVzeSNMkU5PVwjwtZ8EDRQxaR6paXfFAdhMQxmaV', + toUserAccount: 'DsYwEVzeSNMkU5PVwjwtZ8EDRQxaR6paXfFAdhMQxmaV', + tokenAmount: 0.5, + decimals: 0, + mint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + tokenStandard: 'Fungible', + }, + ], + nativeTransfers: [], + accountData: [ + { + account: 'DsYwEVzeSNMkU5PVwjwtZ8EDRQxaR6paXfFAdhMQxmaV', + nativeBalanceChange: -5000, + tokenBalanceChanges: [], + }, + { + account: '8suMXgzzmbBpc4s12REVd6EbvmNhpEq7eAfEyj4DQSeH', + nativeBalanceChange: 0, + tokenBalanceChanges: [], + }, + { + account: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + nativeBalanceChange: 0, + tokenBalanceChanges: [], + }, + { + account: 'ComputeBudget111111111111111111111111111111', + nativeBalanceChange: 0, + tokenBalanceChanges: [], + }, + ], + transactionError: null, + instructions: [ + { + accounts: [ + '8suMXgzzmbBpc4s12REVd6EbvmNhpEq7eAfEyj4DQSeH', + '8suMXgzzmbBpc4s12REVd6EbvmNhpEq7eAfEyj4DQSeH', + 'DsYwEVzeSNMkU5PVwjwtZ8EDRQxaR6paXfFAdhMQxmaV', + ], + data: '3Jv73z5Y9SRV', + programId: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + innerInstructions: [], + }, + { + accounts: [], + data: 'Fn1tZ5', + programId: 'ComputeBudget111111111111111111111111111111', + innerInstructions: [], + }, + { + accounts: [], + data: '3DTZbgwsozUF', + programId: 'ComputeBudget111111111111111111111111111111', + innerInstructions: [], + }, + ], + events: { + compressed: null, + nft: null, + swap: null, + }, +} +export default { tx } diff --git a/packages/unchained-client/src/solana/parser/__tests__/mockData/tokenStandard.ts b/packages/unchained-client/src/solana/parser/__tests__/mockData/tokenStandard.ts new file mode 100644 index 00000000000..c8d357b8cf1 --- /dev/null +++ b/packages/unchained-client/src/solana/parser/__tests__/mockData/tokenStandard.ts @@ -0,0 +1,107 @@ +import type { Tx } from '../../..' + +const tx: Tx = { + txid: 'KquujeLfsAVaYCP7N9BNpkQKwNgUH7EJie6ko8VUbhocmQHENufKAqZaCRYyjsCYTTytHuzBQeEz9tGx6vGigLA', + blockHeight: 297093008, + description: + 'DsYwEVzeSNMkU5PVwjwtZ8EDRQxaR6paXfFAdhMQxmaV transferred 0.1 USDC to 9cErDgnadHNmEBMVn3hDAbooRDgnazfVNKhTF5SEQ8RN.', + type: 'TRANSFER', + source: 'SOLANA_PROGRAM_LIBRARY', + fee: 5000, + feePayer: 'DsYwEVzeSNMkU5PVwjwtZ8EDRQxaR6paXfFAdhMQxmaV', + signature: + 'KquujeLfsAVaYCP7N9BNpkQKwNgUH7EJie6ko8VUbhocmQHENufKAqZaCRYyjsCYTTytHuzBQeEz9tGx6vGigLA', + slot: 297093008, + timestamp: 1729620332, + tokenTransfers: [ + { + fromTokenAccount: '8suMXgzzmbBpc4s12REVd6EbvmNhpEq7eAfEyj4DQSeH', + toTokenAccount: 'Eb3quTucZ9FGRMLtGzkrmzNFDZgzM1F8x56VyvBY5SZV', + fromUserAccount: 'DsYwEVzeSNMkU5PVwjwtZ8EDRQxaR6paXfFAdhMQxmaV', + toUserAccount: '9cErDgnadHNmEBMVn3hDAbooRDgnazfVNKhTF5SEQ8RN', + tokenAmount: 0.1, + decimals: 0, + mint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + tokenStandard: 'Fungible', + }, + ], + nativeTransfers: [], + accountData: [ + { + account: 'DsYwEVzeSNMkU5PVwjwtZ8EDRQxaR6paXfFAdhMQxmaV', + nativeBalanceChange: -5000, + tokenBalanceChanges: [], + }, + { + account: '8suMXgzzmbBpc4s12REVd6EbvmNhpEq7eAfEyj4DQSeH', + nativeBalanceChange: 0, + tokenBalanceChanges: [ + { + userAccount: 'DsYwEVzeSNMkU5PVwjwtZ8EDRQxaR6paXfFAdhMQxmaV', + tokenAccount: '8suMXgzzmbBpc4s12REVd6EbvmNhpEq7eAfEyj4DQSeH', + rawTokenAmount: { + tokenAmount: '-100000', + decimals: 6, + }, + mint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + }, + ], + }, + { + account: 'Eb3quTucZ9FGRMLtGzkrmzNFDZgzM1F8x56VyvBY5SZV', + nativeBalanceChange: 0, + tokenBalanceChanges: [ + { + userAccount: '9cErDgnadHNmEBMVn3hDAbooRDgnazfVNKhTF5SEQ8RN', + tokenAccount: 'Eb3quTucZ9FGRMLtGzkrmzNFDZgzM1F8x56VyvBY5SZV', + rawTokenAmount: { + tokenAmount: '100000', + decimals: 6, + }, + mint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + }, + ], + }, + { + account: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + nativeBalanceChange: 0, + tokenBalanceChanges: [], + }, + { + account: 'ComputeBudget111111111111111111111111111111', + nativeBalanceChange: 0, + tokenBalanceChanges: [], + }, + ], + transactionError: null, + instructions: [ + { + accounts: [ + '8suMXgzzmbBpc4s12REVd6EbvmNhpEq7eAfEyj4DQSeH', + 'Eb3quTucZ9FGRMLtGzkrmzNFDZgzM1F8x56VyvBY5SZV', + 'DsYwEVzeSNMkU5PVwjwtZ8EDRQxaR6paXfFAdhMQxmaV', + ], + data: '3gJqkocMWaMm', + programId: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + innerInstructions: [], + }, + { + accounts: [], + data: 'G8n3yq', + programId: 'ComputeBudget111111111111111111111111111111', + innerInstructions: [], + }, + { + accounts: [], + data: '3DTZbgwsozUF', + programId: 'ComputeBudget111111111111111111111111111111', + innerInstructions: [], + }, + ], + events: { + compressed: null, + nft: null, + swap: null, + }, +} +export default { tx } diff --git a/packages/unchained-client/src/solana/parser/__tests__/mockData/tokenStandardWithCreate.ts b/packages/unchained-client/src/solana/parser/__tests__/mockData/tokenStandardWithCreate.ts new file mode 100644 index 00000000000..03b6ae95b46 --- /dev/null +++ b/packages/unchained-client/src/solana/parser/__tests__/mockData/tokenStandardWithCreate.ts @@ -0,0 +1,173 @@ +import type { Tx } from '../../..' + +const tx: Tx = { + txid: '2gEeoU9sm8udz2vD8D98cEd9cESbetrQPXt4HhCtreQkg4Zhyo3ngzf8rM6yChiVBrZubaswrAST1y8fLEBAPxry', + blockHeight: 297092291, + description: + 'DsYwEVzeSNMkU5PVwjwtZ8EDRQxaR6paXfFAdhMQxmaV transferred 0.1 USDC to 9cErDgnadHNmEBMVn3hDAbooRDgnazfVNKhTF5SEQ8RN.', + type: 'TRANSFER', + source: 'SOLANA_PROGRAM_LIBRARY', + fee: 5000, + feePayer: 'DsYwEVzeSNMkU5PVwjwtZ8EDRQxaR6paXfFAdhMQxmaV', + signature: + '2gEeoU9sm8udz2vD8D98cEd9cESbetrQPXt4HhCtreQkg4Zhyo3ngzf8rM6yChiVBrZubaswrAST1y8fLEBAPxry', + slot: 297092291, + timestamp: 1729619989, + tokenTransfers: [ + { + fromTokenAccount: '8suMXgzzmbBpc4s12REVd6EbvmNhpEq7eAfEyj4DQSeH', + toTokenAccount: 'Eb3quTucZ9FGRMLtGzkrmzNFDZgzM1F8x56VyvBY5SZV', + fromUserAccount: 'DsYwEVzeSNMkU5PVwjwtZ8EDRQxaR6paXfFAdhMQxmaV', + toUserAccount: '9cErDgnadHNmEBMVn3hDAbooRDgnazfVNKhTF5SEQ8RN', + tokenAmount: 0.1, + mint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + tokenStandard: 'Fungible', + decimals: 0, + }, + ], + nativeTransfers: [ + { + fromUserAccount: 'DsYwEVzeSNMkU5PVwjwtZ8EDRQxaR6paXfFAdhMQxmaV', + toUserAccount: 'Eb3quTucZ9FGRMLtGzkrmzNFDZgzM1F8x56VyvBY5SZV', + amount: 2039280, + }, + ], + accountData: [ + { + account: 'DsYwEVzeSNMkU5PVwjwtZ8EDRQxaR6paXfFAdhMQxmaV', + nativeBalanceChange: -2044280, + tokenBalanceChanges: [], + }, + { + account: 'Eb3quTucZ9FGRMLtGzkrmzNFDZgzM1F8x56VyvBY5SZV', + nativeBalanceChange: 2039280, + tokenBalanceChanges: [ + { + userAccount: '9cErDgnadHNmEBMVn3hDAbooRDgnazfVNKhTF5SEQ8RN', + tokenAccount: 'Eb3quTucZ9FGRMLtGzkrmzNFDZgzM1F8x56VyvBY5SZV', + rawTokenAmount: { + tokenAmount: '100000', + decimals: 6, + }, + mint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + }, + ], + }, + { + account: '8suMXgzzmbBpc4s12REVd6EbvmNhpEq7eAfEyj4DQSeH', + nativeBalanceChange: 0, + tokenBalanceChanges: [ + { + userAccount: 'DsYwEVzeSNMkU5PVwjwtZ8EDRQxaR6paXfFAdhMQxmaV', + tokenAccount: '8suMXgzzmbBpc4s12REVd6EbvmNhpEq7eAfEyj4DQSeH', + rawTokenAmount: { + tokenAmount: '-100000', + decimals: 6, + }, + mint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + }, + ], + }, + { + account: 'ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL', + nativeBalanceChange: 0, + tokenBalanceChanges: [], + }, + { + account: '9cErDgnadHNmEBMVn3hDAbooRDgnazfVNKhTF5SEQ8RN', + nativeBalanceChange: 0, + tokenBalanceChanges: [], + }, + { + account: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + nativeBalanceChange: 0, + tokenBalanceChanges: [], + }, + { + account: '11111111111111111111111111111111', + nativeBalanceChange: 0, + tokenBalanceChanges: [], + }, + { + account: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + nativeBalanceChange: 0, + tokenBalanceChanges: [], + }, + { + account: 'ComputeBudget111111111111111111111111111111', + nativeBalanceChange: 0, + tokenBalanceChanges: [], + }, + ], + transactionError: null, + instructions: [ + { + accounts: [ + 'DsYwEVzeSNMkU5PVwjwtZ8EDRQxaR6paXfFAdhMQxmaV', + 'Eb3quTucZ9FGRMLtGzkrmzNFDZgzM1F8x56VyvBY5SZV', + '9cErDgnadHNmEBMVn3hDAbooRDgnazfVNKhTF5SEQ8RN', + 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + '11111111111111111111111111111111', + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + ], + data: '', + programId: 'ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL', + innerInstructions: [ + { + accounts: ['EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'], + data: '84eT', + programId: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + }, + { + accounts: [ + 'DsYwEVzeSNMkU5PVwjwtZ8EDRQxaR6paXfFAdhMQxmaV', + 'Eb3quTucZ9FGRMLtGzkrmzNFDZgzM1F8x56VyvBY5SZV', + ], + data: '11119os1e9qSs2u7TsThXqkBSRVFxhmYaFKFZ1waB2X7armDmvK3p5GmLdUxYdg3h7QSrL', + programId: '11111111111111111111111111111111', + }, + { + accounts: ['Eb3quTucZ9FGRMLtGzkrmzNFDZgzM1F8x56VyvBY5SZV'], + data: 'P', + programId: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + }, + { + accounts: [ + 'Eb3quTucZ9FGRMLtGzkrmzNFDZgzM1F8x56VyvBY5SZV', + 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + ], + data: '6VjwX9tKNv8ebVfT1sCtYeaFBv7GbzjwC5EsJjf83hGrL', + programId: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + }, + ], + }, + { + accounts: [ + '8suMXgzzmbBpc4s12REVd6EbvmNhpEq7eAfEyj4DQSeH', + 'Eb3quTucZ9FGRMLtGzkrmzNFDZgzM1F8x56VyvBY5SZV', + 'DsYwEVzeSNMkU5PVwjwtZ8EDRQxaR6paXfFAdhMQxmaV', + ], + data: '3gJqkocMWaMm', + programId: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + innerInstructions: [], + }, + { + accounts: [], + data: 'H1eW63', + programId: 'ComputeBudget111111111111111111111111111111', + innerInstructions: [], + }, + { + accounts: [], + data: '3DTZbgwsozUF', + programId: 'ComputeBudget111111111111111111111111111111', + innerInstructions: [], + }, + ], + events: { + compressed: null, + nft: null, + swap: null, + }, +} +export default { tx } diff --git a/packages/unchained-client/src/solana/parser/__tests__/mockData/tokens.ts b/packages/unchained-client/src/solana/parser/__tests__/mockData/tokens.ts new file mode 100644 index 00000000000..a83f537f407 --- /dev/null +++ b/packages/unchained-client/src/solana/parser/__tests__/mockData/tokens.ts @@ -0,0 +1,17 @@ +import type { Token as ParserToken } from '../../../../types' +import type { Token as ApiToken } from '../../types' + +export const usdcApiToken: ApiToken = { + id: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + name: 'USD Coin', + symbol: 'USDC', + decimals: 6, + type: 'FungibleToken', +} + +export const usdcParserToken: ParserToken = { + contract: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + name: 'USD Coin', + symbol: 'USDC', + decimals: 6, +} diff --git a/packages/unchained-client/src/solana/parser/__tests__/solana.test.ts b/packages/unchained-client/src/solana/parser/__tests__/solana.test.ts index d6a5eb71511..73c5aa25635 100644 --- a/packages/unchained-client/src/solana/parser/__tests__/solana.test.ts +++ b/packages/unchained-client/src/solana/parser/__tests__/solana.test.ts @@ -2,12 +2,33 @@ import { solanaChainId, solAssetId } from '@shapeshiftoss/caip' import { beforeAll, describe, expect, it, vi } from 'vitest' import { TransferType, TxStatus } from '../../../types' -import type { ParsedTx } from '../../parser' +import type { ParsedTx, Token } from '../../parser' +import { V1Api } from '../../parser' import { TransactionParser } from '../index' import solSelfSend from './mockData/solSelfSend' import solStandard from './mockData/solStandard' +import { usdcApiToken, usdcParserToken } from './mockData/tokens' +import tokenSelfSend from './mockData/tokenSelfSend' +import tokenStandard from './mockData/tokenStandard' +import tokenStandardWithCreate from './mockData/tokenStandardWithCreate' -const txParser = new TransactionParser({ assetId: solAssetId, chainId: solanaChainId }) +const getTokenMock = vi.fn() + +vi.mock('../../../generated/solana', async importActual => { + const actual = await importActual() + return { + ...actual, + V1Api: vi.fn().mockImplementation(() => ({ + getToken: getTokenMock, + })), + } +}) + +const txParser = new TransactionParser({ + assetId: solAssetId, + chainId: solanaChainId, + api: new V1Api(), +}) describe('parseTx', () => { beforeAll(() => { @@ -78,6 +99,124 @@ describe('parseTx', () => { expect(actual).toEqual(expected) }) + + it('should be able to parse token send', async () => { + const { tx } = tokenStandard + const address = 'DsYwEVzeSNMkU5PVwjwtZ8EDRQxaR6paXfFAdhMQxmaV' + + getTokenMock.mockResolvedValueOnce(usdcApiToken) + + const expected: ParsedTx = { + address, + blockHash: tx.blockHash, + blockHeight: tx.blockHeight, + blockTime: tx.timestamp, + chainId: solanaChainId, + confirmations: 1, + fee: { + assetId: solAssetId, + value: '5000', + }, + status: TxStatus.Confirmed, + transfers: [ + { + assetId: + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + components: [{ value: '100000' }], + from: address, + to: '9cErDgnadHNmEBMVn3hDAbooRDgnazfVNKhTF5SEQ8RN', + token: usdcParserToken, + totalValue: '100000', + type: TransferType.Send, + }, + ], + txid: tx.txid, + } + + const actual = await txParser.parse(tx, address) + + expect(actual).toEqual(expected) + }) + + it('should be able to parse token receive', async () => { + const { tx } = tokenStandard + const address = '9cErDgnadHNmEBMVn3hDAbooRDgnazfVNKhTF5SEQ8RN' + + getTokenMock.mockResolvedValueOnce(usdcApiToken) + + const expected: ParsedTx = { + address, + blockHash: tx.blockHash, + blockHeight: tx.blockHeight, + blockTime: tx.timestamp, + chainId: solanaChainId, + confirmations: 1, + status: TxStatus.Confirmed, + transfers: [ + { + assetId: + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + components: [{ value: '100000' }], + from: 'DsYwEVzeSNMkU5PVwjwtZ8EDRQxaR6paXfFAdhMQxmaV', + to: address, + token: usdcParserToken, + totalValue: '100000', + type: TransferType.Receive, + }, + ], + txid: tx.txid, + } + + const actual = await txParser.parse(tx, address) + + expect(actual).toEqual(expected) + }) + + it('should be able to parse token send with create', async () => { + const { tx } = tokenStandardWithCreate + const address = 'DsYwEVzeSNMkU5PVwjwtZ8EDRQxaR6paXfFAdhMQxmaV' + + getTokenMock.mockResolvedValueOnce(usdcApiToken) + + const expected: ParsedTx = { + address, + blockHash: tx.blockHash, + blockHeight: tx.blockHeight, + blockTime: tx.timestamp, + chainId: solanaChainId, + confirmations: 1, + fee: { + assetId: solAssetId, + value: '5000', + }, + status: TxStatus.Confirmed, + transfers: [ + { + assetId: solAssetId, + components: [{ value: '2039280' }], + from: address, + to: 'Eb3quTucZ9FGRMLtGzkrmzNFDZgzM1F8x56VyvBY5SZV', + totalValue: '2039280', + type: TransferType.Send, + }, + { + assetId: + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + components: [{ value: '100000' }], + from: address, + to: '9cErDgnadHNmEBMVn3hDAbooRDgnazfVNKhTF5SEQ8RN', + token: usdcParserToken, + totalValue: '100000', + type: TransferType.Send, + }, + ], + txid: tx.txid, + } + + const actual = await txParser.parse(tx, address) + + expect(actual).toEqual(expected) + }) }) describe('self send', () => { @@ -95,25 +234,73 @@ describe('parseTx', () => { confirmations: 1, status: TxStatus.Confirmed, fee: { - value: '25000', assetId: solAssetId, + value: '25000', }, transfers: [ { - type: TransferType.Send, + assetId: solAssetId, + components: [{ value: '1' }], from: address, to: address, - assetId: solAssetId, totalValue: '1', - components: [{ value: '1' }], + type: TransferType.Send, }, { - type: TransferType.Receive, + assetId: solAssetId, + components: [{ value: '1' }], from: address, to: address, - assetId: solAssetId, totalValue: '1', - components: [{ value: '1' }], + type: TransferType.Receive, + }, + ], + } + + const actual = await txParser.parse(tx, address) + + expect(actual).toEqual(expected) + }) + + it('should be able to parse token', async () => { + const { tx } = tokenSelfSend + const address = 'DsYwEVzeSNMkU5PVwjwtZ8EDRQxaR6paXfFAdhMQxmaV' + + getTokenMock.mockResolvedValueOnce(usdcApiToken) + + const expected: ParsedTx = { + txid: tx.txid, + blockHash: tx.blockHash, + blockHeight: tx.blockHeight, + blockTime: tx.timestamp, + address, + chainId: solanaChainId, + confirmations: 1, + status: TxStatus.Confirmed, + fee: { + assetId: solAssetId, + value: '5000', + }, + transfers: [ + { + assetId: + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + components: [{ value: '500000' }], + from: address, + to: address, + token: usdcParserToken, + totalValue: '500000', + type: TransferType.Send, + }, + { + assetId: + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + components: [{ value: '500000' }], + from: address, + to: address, + token: usdcParserToken, + totalValue: '500000', + type: TransferType.Receive, }, ], } diff --git a/packages/unchained-client/src/solana/parser/index.ts b/packages/unchained-client/src/solana/parser/index.ts index c514f2e5ba9..1869b1f944d 100644 --- a/packages/unchained-client/src/solana/parser/index.ts +++ b/packages/unchained-client/src/solana/parser/index.ts @@ -1,25 +1,33 @@ import type { AssetId, ChainId } from '@shapeshiftoss/caip' +import { ASSET_NAMESPACE, toAssetId } from '@shapeshiftoss/caip' +import { toBaseUnit } from '@shapeshiftoss/utils' import { TransferType, TxStatus } from '../../types' +import type { AggregateTransferArgs } from '../../utils' import { aggregateTransfer } from '../../utils' +import type { Api } from '..' import type { ParsedTx, SubParser, Tx } from './types' +import { TokenStandard } from './types' export * from './types' export interface TransactionParserArgs { chainId: ChainId assetId: AssetId + api: Api } export class TransactionParser { chainId: ChainId assetId: AssetId + api: Api private parsers: SubParser[] = [] constructor(args: TransactionParserArgs) { this.chainId = args.chainId this.assetId = args.assetId + this.api = args.api } /** @@ -57,37 +65,76 @@ export class TransactionParser { } // network fee - if (tx.feePayer === address && tx.fee) { + if (tx.fee && address === tx.feePayer) { parsedTx.fee = { assetId: this.assetId, value: BigInt(tx.fee).toString() } } tx.nativeTransfers?.forEach(nativeTransfer => { const { amount, fromUserAccount, toUserAccount } = nativeTransfer + const makeTransferArgs = (type: TransferType): AggregateTransferArgs => ({ + assetId: this.assetId, + from: fromUserAccount ?? '', + to: toUserAccount ?? '', + transfers: parsedTx.transfers, + type, + value: BigInt(amount).toString(), + }) + // send amount - if (nativeTransfer.fromUserAccount === address) { - parsedTx.transfers = aggregateTransfer({ - assetId: this.assetId, - from: fromUserAccount ?? '', - to: toUserAccount ?? '', - transfers: parsedTx.transfers, - type: TransferType.Send, - value: BigInt(amount).toString(), - }) + if (address === nativeTransfer.fromUserAccount) { + parsedTx.transfers = aggregateTransfer(makeTransferArgs(TransferType.Send)) } // receive amount - if (nativeTransfer.toUserAccount === address) { - parsedTx.transfers = aggregateTransfer({ - assetId: this.assetId, + if (address === nativeTransfer.toUserAccount) { + parsedTx.transfers = aggregateTransfer(makeTransferArgs(TransferType.Receive)) + } + }) + + for (const tokenTransfer of tx.tokenTransfers ?? []) { + const { tokenAmount, fromUserAccount, toUserAccount, mint, tokenStandard } = tokenTransfer + + // only parse fungible tokens + if (tokenStandard !== TokenStandard.Fungible) continue + + try { + const token = await this.api.getToken({ id: mint }) + + const assetId = toAssetId({ + chainId: this.chainId, + assetNamespace: ASSET_NAMESPACE.splToken, + assetReference: mint, + }) + + const makeTransferArgs = (type: TransferType): AggregateTransferArgs => ({ + assetId, from: fromUserAccount ?? '', to: toUserAccount ?? '', + token: { + decimals: token.decimals, + contract: token.id, + name: token.name, + symbol: token.symbol, + }, transfers: parsedTx.transfers, - type: TransferType.Receive, - value: BigInt(amount).toString(), + type, + value: toBaseUnit(tokenAmount, token.decimals).toString(), }) + + // token send amount + if (address === fromUserAccount) { + parsedTx.transfers = aggregateTransfer(makeTransferArgs(TransferType.Send)) + } + + // token receive amount + if (address === toUserAccount) { + parsedTx.transfers = aggregateTransfer(makeTransferArgs(TransferType.Receive)) + } + } catch (err) { + console.warn(`failed to parse token transfer: ${tokenTransfer}: ${err}`) } - }) + } return parsedTx } diff --git a/src/components/AssetHeader/AssetActions.tsx b/src/components/AssetHeader/AssetActions.tsx index 300d5a30e45..74a78523522 100644 --- a/src/components/AssetHeader/AssetActions.tsx +++ b/src/components/AssetHeader/AssetActions.tsx @@ -14,7 +14,6 @@ import { WalletActions } from 'context/WalletProvider/actions' import { useModal } from 'hooks/useModal/useModal' import { useWallet } from 'hooks/useWallet/useWallet' import { bnOrZero } from 'lib/bignumber/bignumber' -import { isSplToken } from 'lib/utils/solana' import { selectSupportsFiatRampByAssetId } from 'state/apis/fiatRamps/selectors' import { selectAssetById } from 'state/slices/selectors' import { useAppSelector } from 'state/store' @@ -205,7 +204,7 @@ export const AssetActions: React.FC = ({ onClick={handleSendClick} leftIcon={arrowUpIcon} width={buttonWidthProps} - isDisabled={!hasValidBalance || !isValidChainId || isNft(assetId) || isSplToken(assetId)} + isDisabled={!hasValidBalance || !isValidChainId || isNft(assetId)} data-test='asset-action-send' flex={buttonFlexProps} > diff --git a/src/components/Modals/Send/utils.ts b/src/components/Modals/Send/utils.ts index f40d08ed226..e32e2a6aedb 100644 --- a/src/components/Modals/Send/utils.ts +++ b/src/components/Modals/Send/utils.ts @@ -1,9 +1,14 @@ import type { AccountId, AssetId, ChainId } from '@shapeshiftoss/caip' import { CHAIN_NAMESPACE, fromAccountId, fromChainId } from '@shapeshiftoss/caip' -import type { FeeData, FeeDataEstimate, GetFeeDataInput } from '@shapeshiftoss/chain-adapters' +import type { + BuildSendTxInput, + FeeData, + FeeDataEstimate, + GetFeeDataInput, +} from '@shapeshiftoss/chain-adapters' import { utxoChainIds } from '@shapeshiftoss/chain-adapters' import type { HDWallet } from '@shapeshiftoss/hdwallet-core' -import { supportsETH } from '@shapeshiftoss/hdwallet-core' +import { supportsETH, supportsSolana } from '@shapeshiftoss/hdwallet-core' import type { CosmosSdkChainId, EvmChainId, KnownChainIds, UtxoChainId } from '@shapeshiftoss/types' import { checkIsMetaMaskDesktop, @@ -88,7 +93,7 @@ export const estimateFees = ({ const getFeeDataInput: GetFeeDataInput = { to, value, - chainSpecific: { from: account }, + chainSpecific: { from: account, tokenId: contractAddress }, } return adapter.getFeeData(getFeeDataInput) } @@ -211,17 +216,23 @@ export const handleSend = async ({ } if (fromChainId(asset.chainId).chainNamespace === CHAIN_NAMESPACE.Solana) { + if (!supportsSolana(wallet)) throw new Error(`useFormSend: wallet does not support solana`) + + const contractAddress = contractAddressOrUndefined(asset.assetId) const fees = estimatedFees[feeType] as FeeData - const input = { + + const input: BuildSendTxInput = { to, value, wallet, accountNumber: bip44Params.accountNumber, chainSpecific: { + tokenId: contractAddress, computeUnitLimit: fees.chainSpecific.computeUnits, computeUnitPrice: fees.chainSpecific.priorityFee, }, } + const adapter = assertGetSolanaChainAdapter(chainId) return adapter.buildSendTransaction(input) } diff --git a/src/components/MultiHopTrade/hooks/useGetTradeQuotes/getTradeQuoteInput.ts b/src/components/MultiHopTrade/hooks/useGetTradeQuotes/getTradeQuoteInput.ts index c30e2a216bd..ff809ccfcb8 100644 --- a/src/components/MultiHopTrade/hooks/useGetTradeQuotes/getTradeQuoteInput.ts +++ b/src/components/MultiHopTrade/hooks/useGetTradeQuotes/getTradeQuoteInput.ts @@ -9,6 +9,7 @@ import { toBaseUnit } from 'lib/math' import { assertUnreachable } from 'lib/utils' import { assertGetCosmosSdkChainAdapter } from 'lib/utils/cosmosSdk' import { assertGetEvmChainAdapter } from 'lib/utils/evm' +import { assertGetSolanaChainAdapter } from 'lib/utils/solana' import { assertGetUtxoChainAdapter } from 'lib/utils/utxo' export type GetTradeQuoteInputArgs = { @@ -152,7 +153,24 @@ export const getTradeQuoteInput = async ({ } } case CHAIN_NAMESPACE.Solana: { - throw new Error('Solana is not supported in getTradeQuoteInput') + const sellAssetChainAdapter = assertGetSolanaChainAdapter(sellAsset.chainId) + + const sendAddress = + wallet && sellAccountNumber !== undefined + ? await sellAssetChainAdapter.getAddress({ + accountNumber: sellAccountNumber, + wallet, + pubKey, + }) + : undefined + + return { + ...tradeQuoteInputCommonArgs, + hasWallet, + chainId: sellAsset.chainId as CosmosSdkChainId, + sendAddress, + receiveAccountNumber, + } as GetTradeQuoteInput } default: assertUnreachable(chainNamespace) diff --git a/src/components/TradeAssetSearch/TradeAssetSearch.tsx b/src/components/TradeAssetSearch/TradeAssetSearch.tsx index a4cc36e8616..c9f0d433fa7 100644 --- a/src/components/TradeAssetSearch/TradeAssetSearch.tsx +++ b/src/components/TradeAssetSearch/TradeAssetSearch.tsx @@ -13,7 +13,6 @@ import { AssetMenuButton } from 'components/AssetSelection/components/AssetMenuB import { AllChainMenu } from 'components/ChainMenu' import { useWallet } from 'hooks/useWallet/useWallet' import { sortChainIdsByDisplayName } from 'lib/utils' -import { isSplToken } from 'lib/utils/solana' import { selectAssetsSortedByMarketCap, selectPortfolioFungibleAssetsSortedByBalance, @@ -106,11 +105,8 @@ export const TradeAssetSearch: FC = ({ const popularAssets = useMemo(() => { const unfilteredPopularAssets = popularAssetsByChainId?.[activeChainId] ?? [] - const filteredPopularAssets = unfilteredPopularAssets.filter( - asset => !isSplToken(asset.assetId), - ) - if (allowWalletUnsupportedAssets || !hasWallet) return filteredPopularAssets - return filteredPopularAssets.filter(asset => walletConnectedChainIds.includes(asset.chainId)) + if (allowWalletUnsupportedAssets || !hasWallet) return unfilteredPopularAssets + return unfilteredPopularAssets.filter(asset => walletConnectedChainIds.includes(asset.chainId)) }, [ popularAssetsByChainId, activeChainId, @@ -143,15 +139,11 @@ export const TradeAssetSearch: FC = ({ }, [activeChainId, popularAssets]) const portfolioAssetsSortedByBalanceForChain = useMemo(() => { - const filteredPortfolioAssetsSortedByBalance = portfolioAssetsSortedByBalance.filter( - asset => !isSplToken(asset.assetId), - ) - if (activeChainId === 'All') { - return filteredPortfolioAssetsSortedByBalance + return portfolioAssetsSortedByBalance } - return filteredPortfolioAssetsSortedByBalance.filter(asset => asset.chainId === activeChainId) + return portfolioAssetsSortedByBalance.filter(asset => asset.chainId === activeChainId) }, [activeChainId, portfolioAssetsSortedByBalance]) const chainIds: (ChainId | 'All')[] = useMemo(() => { diff --git a/src/components/TradeAssetSearch/components/SearchTermAssetList.tsx b/src/components/TradeAssetSearch/components/SearchTermAssetList.tsx index a51fdc8a6c4..d2e78940a2c 100644 --- a/src/components/TradeAssetSearch/components/SearchTermAssetList.tsx +++ b/src/components/TradeAssetSearch/components/SearchTermAssetList.tsx @@ -6,7 +6,6 @@ import { orderBy } from 'lodash' import { useMemo } from 'react' import { ALCHEMY_SUPPORTED_CHAIN_IDS } from 'lib/alchemySdkInstance' import { isSome } from 'lib/utils' -import { isSplToken } from 'lib/utils/solana' import { selectAssetsSortedByName, selectPortfolioUserCurrencyBalances, @@ -56,17 +55,15 @@ export const SearchTermAssetList = ({ }) const assetsForChain = useMemo(() => { - const filteredAssets = assets.filter(asset => !isSplToken(asset.assetId)) - if (activeChainId === 'All') { - if (allowWalletUnsupportedAssets) return filteredAssets - return filteredAssets.filter(asset => walletConnectedChainIds.includes(asset.chainId)) + if (allowWalletUnsupportedAssets) return assets + return assets.filter(asset => walletConnectedChainIds.includes(asset.chainId)) } // Should never happen, but paranoia. if (!allowWalletUnsupportedAssets && !walletConnectedChainIds.includes(activeChainId)) return [] - return filteredAssets.filter(asset => asset.chainId === activeChainId) + return assets.filter(asset => asset.chainId === activeChainId) }, [activeChainId, allowWalletUnsupportedAssets, assets, walletConnectedChainIds]) const customAssets: Asset[] = useMemo( diff --git a/src/lib/utils/index.ts b/src/lib/utils/index.ts index 58237568c16..1b42d2774bc 100644 --- a/src/lib/utils/index.ts +++ b/src/lib/utils/index.ts @@ -19,6 +19,8 @@ import isUndefined from 'lodash/isUndefined' import union from 'lodash/union' import { getChainAdapterManager } from 'context/PluginProvider/chainAdapterSingleton' +import { isSplToken } from './solana' + export const firstFourLastFour = (address: string): string => `${address.slice(0, 6)}...${address.slice(-4)}` @@ -110,7 +112,7 @@ export const isToken = (assetId: AssetId) => { } export const contractAddressOrUndefined = (assetId: AssetId) => - isToken(assetId) ? fromAssetId(assetId).assetReference : undefined + isToken(assetId) || isSplToken(assetId) ? fromAssetId(assetId).assetReference : undefined export const isSome = (option: T | null | undefined): option is T => !isUndefined(option) && !isNull(option) diff --git a/src/state/slices/portfolioSlice/portfolioSlice.ts b/src/state/slices/portfolioSlice/portfolioSlice.ts index 7ff1fa2a52b..57d46633546 100644 --- a/src/state/slices/portfolioSlice/portfolioSlice.ts +++ b/src/state/slices/portfolioSlice/portfolioSlice.ts @@ -1,31 +1,23 @@ import { createSlice } from '@reduxjs/toolkit' import { createApi } from '@reduxjs/toolkit/query/react' import type { AccountId, ChainId } from '@shapeshiftoss/caip' -import { ASSET_NAMESPACE, bscChainId, fromAccountId, isNft, toAssetId } from '@shapeshiftoss/caip' -import type { Account } from '@shapeshiftoss/chain-adapters' -import { evmChainIds } from '@shapeshiftoss/chain-adapters' -import type { AccountMetadataById, EvmChainId } from '@shapeshiftoss/types' -import type { MinimalAsset } from '@shapeshiftoss/utils' -import { makeAsset } from '@shapeshiftoss/utils' +import { fromAccountId } from '@shapeshiftoss/caip' +import type { AccountMetadataById } from '@shapeshiftoss/types' import cloneDeep from 'lodash/cloneDeep' import merge from 'lodash/merge' import uniq from 'lodash/uniq' import { PURGE } from 'redux-persist' import { getChainAdapterManager } from 'context/PluginProvider/chainAdapterSingleton' -import { queryClient } from 'context/QueryClientProvider/queryClient' import { getMixPanel } from 'lib/mixpanel/mixPanelSingleton' import { MixPanelEvent } from 'lib/mixpanel/types' -import { fetchPortalsAccount, fetchPortalsPlatforms, maybeTokenImage } from 'lib/portals/utils' import { BASE_RTK_CREATE_API_CONFIG } from 'state/apis/const' -import { isSpammyNftText, isSpammyTokenText } from 'state/apis/nft/constants' import { selectNftCollections } from 'state/apis/nft/selectors' import type { ReduxState } from 'state/reducer' -import type { UpsertAssetsPayload } from '../assetsSlice/assetsSlice' import { assets as assetSlice } from '../assetsSlice/assetsSlice' import type { Portfolio, WalletId } from './portfolioSliceCommon' import { initialState } from './portfolioSliceCommon' -import { accountToPortfolio, haveSameElements } from './utils' +import { accountToPortfolio, haveSameElements, makeAssets } from './utils' type WalletMetaPayload = { walletId: WalletId @@ -184,136 +176,16 @@ export const portfolioApi = createApi({ const nftCollectionsById = selectNftCollections(state) const data = await (async (): Promise => { - // add placeholder non spam assets for evm chains - if (evmChainIds.includes(chainId as EvmChainId)) { - const maybePortalsAccounts = await fetchPortalsAccount(chainId, pubkey) - const maybePortalsPlatforms = await queryClient.fetchQuery({ - queryFn: () => fetchPortalsPlatforms(), - queryKey: ['portalsPlatforms'], - }) - const account = portfolioAccounts[pubkey] as Account + const assets = await makeAssets({ chainId, pubkey, state, portfolioAccounts }) - const assets = (account.chainSpecific.tokens ?? []).reduce( - (prev, token) => { - const isSpam = [token.name, token.symbol].some(text => { - if (isNft(token.assetId)) return isSpammyNftText(text) - return isSpammyTokenText(text) - }) - if (state.assets.byId[token.assetId] || isSpam) return prev - let minimalAsset: MinimalAsset = token - const maybePortalsAsset = maybePortalsAccounts[token.assetId] - if (maybePortalsAsset) { - const isPool = Boolean( - maybePortalsAsset.platform && maybePortalsAsset.tokens?.length, - ) - const platform = maybePortalsPlatforms[maybePortalsAsset.platform] + // upsert placeholder assets + if (assets) dispatch(assetSlice.actions.upsertAssets(assets)) - const name = (() => { - // For single assets, just use the token name - if (!isPool) return maybePortalsAsset.name - // For pools, create a name in the format of " Pool" - // e.g "UniswapV2 ETH/FOX Pool" - const assetSymbols = - maybePortalsAsset.tokens?.map(underlyingToken => { - const assetId = toAssetId({ - chainId, - assetNamespace: - chainId === bscChainId - ? ASSET_NAMESPACE.bep20 - : ASSET_NAMESPACE.erc20, - assetReference: underlyingToken, - }) - const underlyingAsset = state.assets.byId[assetId] - if (!underlyingAsset) return undefined - - // This doesn't generalize, but this'll do, this is only a visual hack to display native asset instead of wrapped - // We could potentially use related assets for this and use primary implementation, though we'd have to remove BTC from there as WBTC and BTC are very - // much different assets on diff networks, i.e can't deposit BTC instead of WBTC automagically like you would with ETH instead of WETH - switch (underlyingAsset.symbol) { - case 'WETH': - return 'ETH' - case 'WBNB': - return 'BNB' - case 'WMATIC': - return 'MATIC' - case 'WAVAX': - return 'AVAX' - default: - return underlyingAsset.symbol - } - }) ?? [] - - // Our best effort to contruct sane name using the native asset -> asset naming hack failed, but thankfully, upstream name is very close e.g - // for "UniswapV2 LP TRUST/WETH", we just have to append "Pool" to that and we're gucci - if (assetSymbols.some(symbol => !symbol)) return `${token.name} Pool` - return `${platform.name} ${assetSymbols.join('/')} Pool` - })() - - const images = maybePortalsAsset.images ?? [] - const [, ...underlyingAssetsImages] = images - const iconOrIcons = (() => { - // There are no underlying tokens' images, return asset icon if it exists - if (!underlyingAssetsImages?.length) - return { icon: state.assets.byId[token.assetId]?.icon } - - if (underlyingAssetsImages.length === 1) { - return { - icon: maybeTokenImage( - maybePortalsAsset.image || underlyingAssetsImages[0], - ), - } - } - // This is a multiple assets pool, populate icons array - if (underlyingAssetsImages.length > 1) - return { - icons: underlyingAssetsImages.map((underlyingAssetsImage, i) => { - // No token at that index, but this isn't reliable as we've found out, it may be missing in tokens but present in images - // However, this has to be an early return and we can't use our own flavour of that asset... because we have no idea which asset it is. - if (!maybePortalsAsset.tokens[i]) - return maybeTokenImage(underlyingAssetsImage) - - const underlyingAssetId = toAssetId({ - chainId, - assetNamespace: - chainId === bscChainId - ? ASSET_NAMESPACE.bep20 - : ASSET_NAMESPACE.erc20, - assetReference: maybePortalsAsset.tokens[i], - }) - const underlyingAsset = state.assets.byId[underlyingAssetId] - // Prioritise our own flavour of icons for that asset if available, else use upstream if present - return underlyingAsset?.icon || maybeTokenImage(underlyingAssetsImage) - }), - icon: undefined, - } - })() - - // @ts-ignore this is the best we can do, some icons *may* be missing - minimalAsset = { - ...minimalAsset, - name, - isPool, - ...iconOrIcons, - } - } - prev.byId[token.assetId] = makeAsset(state.assets.byId, minimalAsset) - prev.ids.push(token.assetId) - return prev - }, - { byId: {}, ids: [] }, - ) - - // upsert placeholder assets - dispatch(assetSlice.actions.upsertAssets(assets)) - - return accountToPortfolio({ - portfolioAccounts, - assetIds: assetIds.concat(assets.ids), - nftCollectionsById, - }) - } - - return accountToPortfolio({ portfolioAccounts, assetIds, nftCollectionsById }) + return accountToPortfolio({ + portfolioAccounts, + assetIds: assetIds.concat(assets?.ids ?? []), + nftCollectionsById, + }) })() upsertOnFetch && dispatch(portfolio.actions.upsertPortfolio(data)) diff --git a/src/state/slices/portfolioSlice/utils/index.ts b/src/state/slices/portfolioSlice/utils/index.ts index f401ce4f175..73fa60da2f2 100644 --- a/src/state/slices/portfolioSlice/utils/index.ts +++ b/src/state/slices/portfolioSlice/utils/index.ts @@ -2,6 +2,7 @@ import type { AccountId, AssetId, ChainId } from '@shapeshiftoss/caip' import { arbitrumChainId, arbitrumNovaChainId, + ASSET_NAMESPACE, avalancheChainId, baseChainId, bchChainId, @@ -26,6 +27,7 @@ import { toAssetId, } from '@shapeshiftoss/caip' import type { Account } from '@shapeshiftoss/chain-adapters' +import { evmChainIds } from '@shapeshiftoss/chain-adapters' import type { HDWallet } from '@shapeshiftoss/hdwallet-core' import { supportsArbitrum, @@ -43,15 +45,21 @@ import { supportsThorchain, } from '@shapeshiftoss/hdwallet-core' import { PhantomHDWallet } from '@shapeshiftoss/hdwallet-phantom' -import type { KnownChainIds, UtxoChainId } from '@shapeshiftoss/types' +import type { Asset, EvmChainId, KnownChainIds, UtxoChainId } from '@shapeshiftoss/types' +import type { MinimalAsset } from '@shapeshiftoss/utils' +import { makeAsset } from '@shapeshiftoss/utils' import { bech32 } from 'bech32' import cloneDeep from 'lodash/cloneDeep' import maxBy from 'lodash/maxBy' +import { queryClient } from 'context/QueryClientProvider/queryClient' import type { BigNumber } from 'lib/bignumber/bignumber' import { bn, bnOrZero } from 'lib/bignumber/bignumber' +import { fetchPortalsAccount, fetchPortalsPlatforms, maybeTokenImage } from 'lib/portals/utils' import { assertUnreachable, firstFourLastFour } from 'lib/utils' -import { isSpammyNftText } from 'state/apis/nft/constants' +import { isSpammyNftText, isSpammyTokenText } from 'state/apis/nft/constants' import type { NftCollectionType } from 'state/apis/nft/types' +import type { ReduxState } from 'state/reducer' +import type { UpsertAssetsPayload } from 'state/slices/assetsSlice/assetsSlice' import type { Portfolio, @@ -399,3 +407,143 @@ export const haveSameElements = (arr1: T[], arr2: T[]) => { return sortedArr1.every((el1, i) => el1 === sortedArr2[i]) } + +export const makeAssets = async ({ + chainId, + pubkey, + state, + portfolioAccounts, +}: { + chainId: ChainId + pubkey: string + state: ReduxState + portfolioAccounts: Record> +}): Promise => { + if (evmChainIds.includes(chainId as EvmChainId)) { + const account = portfolioAccounts[pubkey] as Account + const assetNamespace = chainId === bscChainId ? ASSET_NAMESPACE.bep20 : ASSET_NAMESPACE.erc20 + + const maybePortalsAccounts = await fetchPortalsAccount(chainId, pubkey) + const maybePortalsPlatforms = await queryClient.fetchQuery({ + queryFn: () => fetchPortalsPlatforms(), + queryKey: ['portalsPlatforms'], + }) + + return (account.chainSpecific.tokens ?? []).reduce( + (prev, token) => { + const isSpam = [token.name, token.symbol].some(text => { + if (isNft(token.assetId)) return isSpammyNftText(text) + return isSpammyTokenText(text) + }) + + if (state.assets.byId[token.assetId] || isSpam) return prev + + const minimalAsset: MinimalAsset = token + + const maybePortalsAsset = maybePortalsAccounts[token.assetId] + if (maybePortalsAsset) { + const isPool = Boolean(maybePortalsAsset.platform && maybePortalsAsset.tokens?.length) + const platform = maybePortalsPlatforms[maybePortalsAsset.platform] + + const name = (() => { + // For single assets, just use the token name + if (!isPool) return maybePortalsAsset.name + + // For pools, create a name in the format of " Pool" + // e.g "UniswapV2 ETH/FOX Pool" + const assetSymbols = + maybePortalsAsset.tokens?.map(token => { + const assetId = toAssetId({ chainId, assetNamespace, assetReference: token }) + const asset = state.assets.byId[assetId] + + if (!asset) return undefined + + // This doesn't generalize, but this'll do, this is only a visual hack to display native asset instead of wrapped + // We could potentially use related assets for this and use primary implementation, though we'd have to remove BTC from there as WBTC and BTC are very + // much different assets on diff networks, i.e can't deposit BTC instead of WBTC automagically like you would with ETH instead of WETH + switch (asset.symbol) { + case 'WETH': + return 'ETH' + case 'WBNB': + return 'BNB' + case 'WMATIC': + return 'MATIC' + case 'WAVAX': + return 'AVAX' + default: + return asset.symbol + } + }) ?? [] + + // Our best effort to contruct sane name using the native asset -> asset naming hack failed, but thankfully, upstream name is very close e.g + // for "UniswapV2 LP TRUST/WETH", we just have to append "Pool" to that and we're gucci + if (assetSymbols.some(symbol => !symbol)) return `${token.name} Pool` + return `${platform.name} ${assetSymbols.join('/')} Pool` + })() + + const [, ...assetImages] = maybePortalsAsset.images ?? [] + + const { icon, icons } = ((): Pick => { + // There are no underlying tokens' images, return asset icon if it exists + if (!assetImages?.length) { + return { icon: state.assets.byId[token.assetId]?.icon } + } + + if (assetImages.length === 1) { + return { icon: maybeTokenImage(maybePortalsAsset.image || assetImages[0]) } + } + + // This is a multiple assets pool, populate icons array + if (assetImages.length > 1) { + return { + icons: assetImages.map((image, i) => { + const token = maybePortalsAsset.tokens[i] + + // No token at that index, but this isn't reliable as we've found out, it may be missing in tokens but present in images + // However, this has to be an early return and we can't use our own flavour of that asset... because we have no idea which asset it is. + if (!token) return maybeTokenImage(image) || '' + + const assetId = toAssetId({ chainId, assetNamespace, assetReference: token }) + const asset = state.assets.byId[assetId] + + // Prioritise our own flavour of icons for that asset if available, else use upstream if present + return asset?.icon || maybeTokenImage(image) || '' + }), + icon: undefined, + } + } + + return { icon: undefined, icons: undefined } + })() + + minimalAsset.name = name + minimalAsset.isPool = isPool + minimalAsset.icon = icon + minimalAsset.icons = icons + } + + prev.byId[token.assetId] = makeAsset(state.assets.byId, minimalAsset) + prev.ids.push(token.assetId) + + return prev + }, + { byId: {}, ids: [] }, + ) + } + + if (chainId === solanaChainId) { + const account = portfolioAccounts[pubkey] as Account + + return (account.chainSpecific.tokens ?? []).reduce( + (prev, token) => { + if (state.assets.byId[token.assetId]) return prev + + prev.byId[token.assetId] = makeAsset(state.assets.byId, { ...token }) + prev.ids.push(token.assetId) + + return prev + }, + { byId: {}, ids: [] }, + ) + } +} diff --git a/yarn.lock b/yarn.lock index 1708854d4c1..4c6c3a6c7b6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11135,6 +11135,7 @@ __metadata: "@shapeshiftoss/types": "workspace:^" "@shapeshiftoss/unchained-client": "workspace:^" "@shapeshiftoss/utils": "workspace:^" + "@solana/spl-token": ^0.4.9 "@solana/web3.js": ^1.95.3 "@types/multicoin-address-validator": ^0.5.0 bech32: ^2.0.0 @@ -11604,6 +11605,8 @@ __metadata: "@shapeshiftoss/caip": "workspace:^" "@shapeshiftoss/common-api": ^9.3.0 "@shapeshiftoss/contracts": "workspace:^" + "@solana/spl-token": ^0.4.9 + "@solana/web3.js": ^1.95.4 isomorphic-ws: ^4.0.1 ws: ^8.17.1 languageName: unknown @@ -11911,6 +11914,18 @@ __metadata: languageName: node linkType: hard +"@solana/buffer-layout-utils@npm:^0.2.0": + version: 0.2.0 + resolution: "@solana/buffer-layout-utils@npm:0.2.0" + dependencies: + "@solana/buffer-layout": ^4.0.0 + "@solana/web3.js": ^1.32.0 + bigint-buffer: ^1.1.5 + bignumber.js: ^9.0.1 + checksum: 9284242245b18b49577195ba7548263850be865a4a2d183944fa01bb76382039db589aab8473698e9bb734b515ada9b4d70db0a72e341c5d567c59b83d6d0840 + languageName: node + linkType: hard + "@solana/buffer-layout@npm:^4.0.0": version: 4.0.0 resolution: "@solana/buffer-layout@npm:4.0.0" @@ -11929,6 +11944,122 @@ __metadata: languageName: node linkType: hard +"@solana/codecs-core@npm:2.0.0-rc.1": + version: 2.0.0-rc.1 + resolution: "@solana/codecs-core@npm:2.0.0-rc.1" + dependencies: + "@solana/errors": 2.0.0-rc.1 + peerDependencies: + typescript: ">=5" + checksum: e3a138cbdc2b87c6296c449384b684ca2f90cf212cee1cf0a1f30385c3acc72c9a3dc2e60e3152723b9fa5640635bcf69ce06581d83113986ede05d41139f0ba + languageName: node + linkType: hard + +"@solana/codecs-data-structures@npm:2.0.0-rc.1": + version: 2.0.0-rc.1 + resolution: "@solana/codecs-data-structures@npm:2.0.0-rc.1" + dependencies: + "@solana/codecs-core": 2.0.0-rc.1 + "@solana/codecs-numbers": 2.0.0-rc.1 + "@solana/errors": 2.0.0-rc.1 + peerDependencies: + typescript: ">=5" + checksum: 7c24700be7c935fc066dc70e1a02c32d9f17393d3898074e6dcff2c2083012dc8c5583fff5ee04f8ce4578c57bb708688f5e52aad301aa5543aab293640a0b21 + languageName: node + linkType: hard + +"@solana/codecs-numbers@npm:2.0.0-rc.1": + version: 2.0.0-rc.1 + resolution: "@solana/codecs-numbers@npm:2.0.0-rc.1" + dependencies: + "@solana/codecs-core": 2.0.0-rc.1 + "@solana/errors": 2.0.0-rc.1 + peerDependencies: + typescript: ">=5" + checksum: 370c1f94970e969b1f523d47714ddd751b68f622967455e2376590af0f230e027dce365bd54a9017fbc064ed21447f795c775c46285222c42a11b8ed46a41570 + languageName: node + linkType: hard + +"@solana/codecs-strings@npm:2.0.0-rc.1": + version: 2.0.0-rc.1 + resolution: "@solana/codecs-strings@npm:2.0.0-rc.1" + dependencies: + "@solana/codecs-core": 2.0.0-rc.1 + "@solana/codecs-numbers": 2.0.0-rc.1 + "@solana/errors": 2.0.0-rc.1 + peerDependencies: + fastestsmallesttextencoderdecoder: ^1.0.22 + typescript: ">=5" + checksum: 0706605311508b02f7dc4bfde6f93237337ecde051c83f172a121b52676e2a21af90f916624f57c0e80bbe420412ed98c1e7ae90a583761b028cc6a883fa4a0e + languageName: node + linkType: hard + +"@solana/codecs@npm:2.0.0-rc.1": + version: 2.0.0-rc.1 + resolution: "@solana/codecs@npm:2.0.0-rc.1" + dependencies: + "@solana/codecs-core": 2.0.0-rc.1 + "@solana/codecs-data-structures": 2.0.0-rc.1 + "@solana/codecs-numbers": 2.0.0-rc.1 + "@solana/codecs-strings": 2.0.0-rc.1 + "@solana/options": 2.0.0-rc.1 + peerDependencies: + typescript: ">=5" + checksum: 8586abfd1e2792008a447c29efc22e0bfefd7d97a8025090dd49ec07c8c860e51c44355ab74faf43e23336f3dd2e1353238fa4b009d3fe60ff3f02b46a96aa04 + languageName: node + linkType: hard + +"@solana/errors@npm:2.0.0-rc.1": + version: 2.0.0-rc.1 + resolution: "@solana/errors@npm:2.0.0-rc.1" + dependencies: + chalk: ^5.3.0 + commander: ^12.1.0 + peerDependencies: + typescript: ">=5" + bin: + errors: bin/cli.mjs + checksum: 906892a892d250c2236449b875b174e0e19ade788146d0e63da23c83d89b98a762770c276341fae8f73959efc57d01090e0e979d111b12ac0e451f2402a8d092 + languageName: node + linkType: hard + +"@solana/options@npm:2.0.0-rc.1": + version: 2.0.0-rc.1 + resolution: "@solana/options@npm:2.0.0-rc.1" + dependencies: + "@solana/codecs-core": 2.0.0-rc.1 + "@solana/codecs-data-structures": 2.0.0-rc.1 + "@solana/codecs-numbers": 2.0.0-rc.1 + "@solana/codecs-strings": 2.0.0-rc.1 + "@solana/errors": 2.0.0-rc.1 + peerDependencies: + typescript: ">=5" + checksum: 63f3ed04e56ca232023fcf35ddab8f01a1ba7aae932990908657dec833ff4133ad0af279417d4bce291367903dbd3b962287796a22dd0aaa1d3e3290613a442e + languageName: node + linkType: hard + +"@solana/spl-token-group@npm:^0.0.7": + version: 0.0.7 + resolution: "@solana/spl-token-group@npm:0.0.7" + dependencies: + "@solana/codecs": 2.0.0-rc.1 + peerDependencies: + "@solana/web3.js": ^1.95.3 + checksum: 8a47c409ca185d89c6572848a2a496ad3f70ef7b1efac2960b06836996870c861d3c8d920032451c9dda90765c7a4b484d883449331fdf859911436bfe9e2ad6 + languageName: node + linkType: hard + +"@solana/spl-token-metadata@npm:^0.1.6": + version: 0.1.6 + resolution: "@solana/spl-token-metadata@npm:0.1.6" + dependencies: + "@solana/codecs": 2.0.0-rc.1 + peerDependencies: + "@solana/web3.js": ^1.95.3 + checksum: a19d7d659c3fca375312e86cf4b0a2077327b220462b46a8627f0cc1892c97ce34cfbe9c3645620496d7b1177d56628b16a7357cd61314e079c1d9c73c944d98 + languageName: node + linkType: hard + "@solana/spl-token@npm:^0.1.8": version: 0.1.8 resolution: "@solana/spl-token@npm:0.1.8" @@ -11943,6 +12074,21 @@ __metadata: languageName: node linkType: hard +"@solana/spl-token@npm:^0.4.9": + version: 0.4.9 + resolution: "@solana/spl-token@npm:0.4.9" + dependencies: + "@solana/buffer-layout": ^4.0.0 + "@solana/buffer-layout-utils": ^0.2.0 + "@solana/spl-token-group": ^0.0.7 + "@solana/spl-token-metadata": ^0.1.6 + buffer: ^6.0.3 + peerDependencies: + "@solana/web3.js": ^1.95.3 + checksum: a1b0433786cff28755cf0ba219dc6e9805f11acd717a0285116445cc60acc0153af5d422c5ffeac429e9d2ebab66045f8d736cc8999d68f60ed317ec2e9bcaea + languageName: node + linkType: hard + "@solana/wallet-adapter-base@npm:^0.9.23": version: 0.9.23 resolution: "@solana/wallet-adapter-base@npm:0.9.23" @@ -12037,6 +12183,29 @@ __metadata: languageName: node linkType: hard +"@solana/web3.js@npm:^1.95.4": + version: 1.95.4 + resolution: "@solana/web3.js@npm:1.95.4" + dependencies: + "@babel/runtime": ^7.25.0 + "@noble/curves": ^1.4.2 + "@noble/hashes": ^1.4.0 + "@solana/buffer-layout": ^4.0.1 + agentkeepalive: ^4.5.0 + bigint-buffer: ^1.1.5 + bn.js: ^5.2.1 + borsh: ^0.7.0 + bs58: ^4.0.1 + buffer: 6.0.3 + fast-stable-stringify: ^1.0.0 + jayson: ^4.1.1 + node-fetch: ^2.7.0 + rpc-websockets: ^9.0.2 + superstruct: ^2.0.2 + checksum: 75a2bc3b0ab0743d0f7b1c36116d2a9c8968d6f1cefa837f654b2d423190fb9ebe9cbb0052fdf39f1d820c8c1aee67c4e8605a2e407a1c7a9d6825959b94b426 + languageName: node + linkType: hard + "@spruceid/siwe-parser@npm:1.1.3": version: 1.1.3 resolution: "@spruceid/siwe-parser@npm:1.1.3" @@ -18818,7 +18987,7 @@ __metadata: languageName: node linkType: hard -"chalk@npm:5.3.0": +"chalk@npm:5.3.0, chalk@npm:^5.3.0": version: 5.3.0 resolution: "chalk@npm:5.3.0" checksum: 623922e077b7d1e9dedaea6f8b9e9352921f8ae3afe739132e0e00c275971bdd331268183b2628cf4ab1727c45ea1f28d7e24ac23ce1db1eb653c414ca8a5a80 @@ -19402,6 +19571,13 @@ __metadata: languageName: node linkType: hard +"commander@npm:^12.1.0": + version: 12.1.0 + resolution: "commander@npm:12.1.0" + checksum: 68e9818b00fc1ed9cdab9eb16905551c2b768a317ae69a5e3c43924c2b20ac9bb65b27e1cab36aeda7b6496376d4da908996ba2c0b5d79463e0fb1e77935d514 + languageName: node + linkType: hard + "commander@npm:^2.20.0, commander@npm:^2.20.3": version: 2.20.3 resolution: "commander@npm:2.20.3"