Skip to content

Commit

Permalink
feat: solana spl token support (#7995)
Browse files Browse the repository at this point in the history
  • Loading branch information
kaladinlight authored Oct 25, 2024
1 parent ab0c7c0 commit 48e5e93
Show file tree
Hide file tree
Showing 20 changed files with 1,148 additions and 210 deletions.
1 change: 1 addition & 0 deletions .env.develop
Original file line number Diff line number Diff line change
@@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/chain-adapters/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
138 changes: 121 additions & 17 deletions packages/chain-adapters/src/solana/SolanaChainAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -84,6 +93,7 @@ export class ChainAdapter implements IChainAdapter<KnownChainIds.SolanaMainnet>
this.parser = new unchained.solana.TransactionParser({
assetId: this.assetId,
chainId: this.chainId,
api: this.providers.http,
})
}

Expand Down Expand Up @@ -197,8 +207,10 @@ export class ChainAdapter implements IChainAdapter<KnownChainIds.SolanaMainnet>
txToSign: SignTx<KnownChainIds.SolanaMainnet>
}> {
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
Expand All @@ -209,15 +221,27 @@ export class ChainAdapter implements IChainAdapter<KnownChainIds.SolanaMainnet>
? 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<KnownChainIds.SolanaMainnet> = {
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 }
Expand Down Expand Up @@ -289,7 +313,7 @@ export class ChainAdapter implements IChainAdapter<KnownChainIds.SolanaMainnet>
): Promise<FeeDataEstimate<KnownChainIds.SolanaMainnet>> {
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 },
})
Expand Down Expand Up @@ -362,18 +386,36 @@ export class ChainAdapter implements IChainAdapter<KnownChainIds.SolanaMainnet>
this.providers.ws.close('txs')
}

private buildEstimationSerializedTx(input: GetFeeDataInput<KnownChainIds.SolanaMainnet>): string {
const instructions = input.chainSpecific.instructions ?? []
private async buildEstimationSerializedTx(
input: GetFeeDataInput<KnownChainIds.SolanaMainnet>,
): Promise<string> {
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,
Expand All @@ -395,6 +437,68 @@ export class ChainAdapter implements IChainAdapter<KnownChainIds.SolanaMainnet>
return Buffer.from(transaction.serialize()).toString('base64')
}

private async buildTokenTransferInstructions({
from,
to,
tokenId,
value,
}: {
from: string
to: string
tokenId: string
value: string
}): Promise<TransactionInstruction[]> {
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<Transaction> {
const { address: _, ...parsedTx } = await this.parser.parse(tx, pubkey)

Expand Down
2 changes: 2 additions & 0 deletions packages/chain-adapters/src/solana/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,13 @@ export type BuildTransactionInput<T extends CosmosSdkChainId> = {
export type BuildTxInput = {
computeUnitLimit?: string
computeUnitPrice?: string
tokenId?: string
instructions?: SolanaTxInstruction[]
}

export type GetFeeDataInput = {
from: string
tokenId?: string
instructions?: TransactionInstruction[]
}

Expand Down
2 changes: 2 additions & 0 deletions packages/unchained-client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
Original file line number Diff line number Diff line change
@@ -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 }
Loading

0 comments on commit 48e5e93

Please sign in to comment.