Search code examples
javascriptcryptographytokensolana

How to transfer custom SPL token by '@solana/web3.js' and '@solana/sol-wallet-adapter'


I am trying to transfer a custom SPL token with the solana-wallet adapter. However I am having trouble getting the wallet's secret key/signing the transaction.

I've looked at these answers for writing the transfer code, but I need to get the Signer and I have trouble figuring out how with solana-wallet adapter:

How can you transfer SOL using the web3.js sdk for Solana?

How to transfer custom token by '@solana/web3.js'

These examples hardcode the secret key and since I'm using a wallet extension this is not possible.

According to this issue on the webadapter repo https://github.com/solana-labs/wallet-adapter/issues/120 you need to:

  1. Create a @solana/web3.js Transaction object and add instructions to it
  2. Sign the transaction with the wallet
  3. Send the transaction over a Connection

But Ii am having difficulty finding examples or documentation as to how to do step 1 and 2.

const SendTransaction: React.FC<Props> = ({ children }) => {
    const { connection } = useConnection()
    const { publicKey, sendTransaction } = useWallet()

    const onSendSPLTransaction = useCallback(
        async (toPubkey: string, amount: number) => {
            if (!toPubkey || !amount) return
            const toastId = toast.loading('Processing transaction...')

            try {
                if (!publicKey) throw new WalletNotConnectedError()
                const toPublicKey = new PublicKey(toPubkey)
                const mint = new PublicKey('Mint address')
                const payer = '????' // how to get this Signer
                const token = new Token(connection, mint, TOKEN_PROGRAM_ID, payer)
                const fromTokenAccount = await token.getOrCreateAssociatedAccountInfo(publicKey)
                const toTokenAccount = await token.getOrCreateAssociatedAccountInfo(toPublicKey)

                const transaction = new Transaction().add(
                    Token.createTransferInstruction(
                        TOKEN_PROGRAM_ID,
                        fromTokenAccount.address,
                        toTokenAccount.address,
                        publicKey,
                        [],
                        0
                    )
                )

                const signature = await sendTransaction(transaction, connection)

                const response = await connection.confirmTransaction(signature, 'processed')
                console.log('response', response)
                toast.success('Transaction sent', {
                    id: toastId,
                })
            } catch (error) {
                toast.error(`Transaction failed: ${error.message}`, {
                    id: toastId,
                })
            }
        },
        [publicKey, sendTransaction, connection]
    )

    return <>{children(onSendSPLTransaction)}</>
}

Solution

  • So i found a way to do this, it requires some cleanup and error handling but allows for a custom token transaction via @solana/wallet-adapter.

    // sendTransaction.tsx
    import { WalletNotConnectedError } from '@solana/wallet-adapter-base'
    import { useConnection, useWallet } from '@solana/wallet-adapter-react'
    import { Transaction, PublicKey, LAMPORTS_PER_SOL } from '@solana/web3.js'
    import React, { useCallback } from 'react'
    import { toast } from 'react-hot-toast'
    import { TOKEN_PROGRAM_ID } from '@solana/spl-token'
    import { getOrCreateAssociatedTokenAccount } from './getOrCreateAssociatedTokenAccount'
    import { createTransferInstruction } from './createTransferInstructions'
    
    interface Props {
        children: (sendTransaction: OnSendTransaction) => React.ReactNode
    }
    
    type OnSendTransaction = (toPublicKey: string, amount: number) => void
    
    // Docs: https://github.com/solana-labs/solana-program-library/pull/2539/files
    // https://github.com/solana-labs/wallet-adapter/issues/189
    // repo: https://github.com/solana-labs/example-token/blob/v1.1/src/client/token.js
    // creating a token for testing: https://learn.figment.io/tutorials/sol-mint-token
    const SendTransaction: React.FC<Props> = ({ children }) => {
        const { connection } = useConnection()
        const { publicKey, signTransaction, sendTransaction } = useWallet()
    
        const onSendSPLTransaction = useCallback(
            async (toPubkey: string, amount: number) => {
                if (!toPubkey || !amount) return
                const toastId = toast.loading('Processing transaction...')
    
                try {
                    if (!publicKey || !signTransaction) throw new WalletNotConnectedError()
                    const toPublicKey = new PublicKey(toPubkey)
                    const mint = new PublicKey('MINT ADDRESS')
    
                    const fromTokenAccount = await getOrCreateAssociatedTokenAccount(
                        connection,
                        publicKey,
                        mint,
                        publicKey,
                        signTransaction
                    )
    
                    const toTokenAccount = await getOrCreateAssociatedTokenAccount(
                        connection,
                        publicKey,
                        mint,
                        toPublicKey,
                        signTransaction
                    )
    
                    const transaction = new Transaction().add(
                        createTransferInstruction(
                            fromTokenAccount.address, // source
                            toTokenAccount.address, // dest
                            publicKey,
                            amount * LAMPORTS_PER_SOL,
                            [],
                            TOKEN_PROGRAM_ID
                        )
                    )
    
                    const blockHash = await connection.getRecentBlockhash()
                    transaction.feePayer = await publicKey
                    transaction.recentBlockhash = await blockHash.blockhash
                    const signed = await signTransaction(transaction)
    
                    await connection.sendRawTransaction(signed.serialize())
    
                    toast.success('Transaction sent', {
                        id: toastId,
                    })
                    // eslint-disable-next-line @typescript-eslint/no-explicit-any
                } catch (error: any) {
                    toast.error(`Transaction failed: ${error.message}`, {
                        id: toastId,
                    })
                }
            },
            [publicKey, sendTransaction, connection]
        )
    
        return <>{children(onSendSPLTransaction)}</>
    }
    
    export default SendTransaction
    
    
    // getOrCreateAssociatedTokenAccount.ts
    import { TOKEN_PROGRAM_ID, ASSOCIATED_TOKEN_PROGRAM_ID } from '@solana/spl-token'
    import { SignerWalletAdapterProps } from '@solana/wallet-adapter-base'
    import { Connection, PublicKey, Commitment, Transaction } from '@solana/web3.js'
    import { createAssociatedTokenAccountInstruction } from './createAssociatedTokenAccountInstruction'
    import { getAccountInfo } from './getAccountInfo'
    import { getAssociatedTokenAddress } from './getAssociatedTokerAddress'
    
    export async function getOrCreateAssociatedTokenAccount(
        connection: Connection,
        payer: PublicKey,
        mint: PublicKey,
        owner: PublicKey,
        signTransaction: SignerWalletAdapterProps['signTransaction'],
        allowOwnerOffCurve = false,
        commitment?: Commitment,
        programId = TOKEN_PROGRAM_ID,
        associatedTokenProgramId = ASSOCIATED_TOKEN_PROGRAM_ID
    ) {
        const associatedToken = await getAssociatedTokenAddress(
            mint,
            owner,
            allowOwnerOffCurve,
            programId,
            associatedTokenProgramId
        )
    
        // This is the optimal logic, considering TX fee, client-side computation, RPC roundtrips and guaranteed idempotent.
        // Sadly we can't do this atomically.
        let account
        try {
            account = await getAccountInfo(connection, associatedToken, commitment, programId)
            // eslint-disable-next-line @typescript-eslint/no-explicit-any
        } catch (error: any) {
            // TokenAccountNotFoundError can be possible if the associated address has already received some lamports,
            // becoming a system account. Assuming program derived addressing is safe, this is the only case for the
            // TokenInvalidAccountOwnerError in this code path.
            if (error.message === 'TokenAccountNotFoundError' || error.message === 'TokenInvalidAccountOwnerError') {
                // As this isn't atomic, it's possible others can create associated accounts meanwhile.
                try {
                    const transaction = new Transaction().add(
                        createAssociatedTokenAccountInstruction(
                            payer,
                            associatedToken,
                            owner,
                            mint,
                            programId,
                            associatedTokenProgramId
                        )
                    )
    
                    const blockHash = await connection.getRecentBlockhash()
                    transaction.feePayer = await payer
                    transaction.recentBlockhash = await blockHash.blockhash
                    const signed = await signTransaction(transaction)
    
                    const signature = await connection.sendRawTransaction(signed.serialize())
    
                    await connection.confirmTransaction(signature)
                } catch (error: unknown) {
                    // Ignore all errors; for now there is no API-compatible way to selectively ignore the expected
                    // instruction error if the associated account exists already.
                }
    
                // Now this should always succeed
                account = await getAccountInfo(connection, associatedToken, commitment, programId)
            } else {
                throw error
            }
        }
    
        if (!account.mint.equals(mint.toBuffer())) throw Error('TokenInvalidMintError')
        if (!account.owner.equals(owner.toBuffer())) throw new Error('TokenInvalidOwnerError')
    
        return account
    }
    
    // createAssociatedTokenAccountInstruction.ts
    import { TOKEN_PROGRAM_ID, ASSOCIATED_TOKEN_PROGRAM_ID } from '@solana/spl-token'
    import { PublicKey, TransactionInstruction, SystemProgram, SYSVAR_RENT_PUBKEY } from '@solana/web3.js'
    
    export function createAssociatedTokenAccountInstruction(
        payer: PublicKey,
        associatedToken: PublicKey,
        owner: PublicKey,
        mint: PublicKey,
        programId = TOKEN_PROGRAM_ID,
        associatedTokenProgramId = ASSOCIATED_TOKEN_PROGRAM_ID
    ): TransactionInstruction {
        const keys = [
            { pubkey: payer, isSigner: true, isWritable: true },
            { pubkey: associatedToken, isSigner: false, isWritable: true },
            { pubkey: owner, isSigner: false, isWritable: false },
            { pubkey: mint, isSigner: false, isWritable: false },
            { pubkey: SystemProgram.programId, isSigner: false, isWritable: false },
            { pubkey: programId, isSigner: false, isWritable: false },
            { pubkey: SYSVAR_RENT_PUBKEY, isSigner: false, isWritable: false },
        ]
    
        return new TransactionInstruction({
            keys,
            programId: associatedTokenProgramId,
            data: Buffer.alloc(0),
        })
    }
    
    
    // createTransferInstructions.ts
    import { TOKEN_PROGRAM_ID } from '@solana/spl-token'
    import { AccountMeta, PublicKey, Signer, TransactionInstruction } from '@solana/web3.js'
    import BufferLayout from 'buffer-layout'
    import BN from 'bn.js'
    
    export enum TokenInstruction {
        InitializeMint = 0,
        InitializeAccount = 1,
        InitializeMultisig = 2,
        Transfer = 3,
        Approve = 4,
        Revoke = 5,
        SetAuthority = 6,
        MintTo = 7,
        Burn = 8,
        CloseAccount = 9,
        FreezeAccount = 10,
        ThawAccount = 11,
        TransferChecked = 12,
        ApproveChecked = 13,
        MintToChecked = 14,
        BurnChecked = 15,
        InitializeAccount2 = 16,
        SyncNative = 17,
        InitializeAccount3 = 18,
        InitializeMultisig2 = 19,
        InitializeMint2 = 20,
    }
    
    /**
     * Construct a Transfer instruction
     *
     * @param source       Source account
     * @param destination  Destination account
     * @param owner        Owner of the source account
     * @param amount       Number of tokens to transfer
     * @param multiSigners Signing accounts if `owner` is a multisig
     * @param programId    SPL Token program account
     *
     * @return Instruction to add to a transaction
     */
    export function createTransferInstruction(
        source: PublicKey,
        destination: PublicKey,
        owner: PublicKey,
        amount: number,
        multiSigners: Signer[] = [],
        programId = TOKEN_PROGRAM_ID
    ): TransactionInstruction {
        const dataLayout = BufferLayout.struct([BufferLayout.u8('instruction'), BufferLayout.blob(8, 'amount')])
    
        const keys = addSigners(
            [
                { pubkey: source, isSigner: false, isWritable: true },
                { pubkey: destination, isSigner: false, isWritable: true },
            ],
            owner,
            multiSigners
        )
    
        const data = Buffer.alloc(dataLayout.span)
        dataLayout.encode(
            {
                instruction: TokenInstruction.Transfer,
                amount: new TokenAmount(amount).toBuffer(),
            },
            data
        )
    
        return new TransactionInstruction({ keys, programId, data })
    }
    
    export function addSigners(keys: AccountMeta[], ownerOrAuthority: PublicKey, multiSigners: Signer[]): AccountMeta[] {
        if (multiSigners.length) {
            keys.push({ pubkey: ownerOrAuthority, isSigner: false, isWritable: false })
            for (const signer of multiSigners) {
                keys.push({ pubkey: signer.publicKey, isSigner: true, isWritable: false })
            }
        } else {
            keys.push({ pubkey: ownerOrAuthority, isSigner: true, isWritable: false })
        }
        return keys
    }
    
    class TokenAmount extends BN {
        /**
         * Convert to Buffer representation
         */
        toBuffer(): Buffer {
            const a = super.toArray().reverse()
            const b = Buffer.from(a)
            if (b.length === 8) {
                return b
            }
    
            if (b.length >= 8) {
                throw new Error('TokenAmount too large')
            }
    
            const zeroPad = Buffer.alloc(8)
            b.copy(zeroPad)
            return zeroPad
        }
    
        /**
         * Construct a TokenAmount from Buffer representation
         */
        static fromBuffer(buffer: Buffer): TokenAmount {
            if (buffer.length !== 8) {
                throw new Error(`Invalid buffer length: ${buffer.length}`)
            }
    
            return new BN(
                [...buffer]
                    .reverse()
                    .map((i) => `00${i.toString(16)}`.slice(-2))
                    .join(''),
                16
            )
        }
    }
    
    // getAccountInfo.ts
    import { TOKEN_PROGRAM_ID, AccountLayout } from '@solana/spl-token'
    import { Connection, PublicKey, Commitment } from '@solana/web3.js'
    
    export enum AccountState {
        Uninitialized = 0,
        Initialized = 1,
        Frozen = 2,
    }
    
    export async function getAccountInfo(
        connection: Connection,
        address: PublicKey,
        commitment?: Commitment,
        programId = TOKEN_PROGRAM_ID
    ) {
        const info = await connection.getAccountInfo(address, commitment)
        if (!info) throw new Error('TokenAccountNotFoundError')
        if (!info.owner.equals(programId)) throw new Error('TokenInvalidAccountOwnerError')
        if (info.data.length != AccountLayout.span) throw new Error('TokenInvalidAccountSizeError')
    
        const rawAccount = AccountLayout.decode(Buffer.from(info.data))
    
        return {
            address,
            mint: rawAccount.mint,
            owner: rawAccount.owner,
            amount: rawAccount.amount,
            delegate: rawAccount.delegateOption ? rawAccount.delegate : null,
            delegatedAmount: rawAccount.delegatedAmount,
            isInitialized: rawAccount.state !== AccountState.Uninitialized,
            isFrozen: rawAccount.state === AccountState.Frozen,
            isNative: !!rawAccount.isNativeOption,
            rentExemptReserve: rawAccount.isNativeOption ? rawAccount.isNative : null,
            closeAuthority: rawAccount.closeAuthorityOption ? rawAccount.closeAuthority : null,
        }
    }
    
    
    // getAssociatedTokerAddress.ts
    import { TOKEN_PROGRAM_ID, ASSOCIATED_TOKEN_PROGRAM_ID } from '@solana/spl-token'
    import { PublicKey } from '@solana/web3.js'
    
    export async function getAssociatedTokenAddress(
        mint: PublicKey,
        owner: PublicKey,
        allowOwnerOffCurve = false,
        programId = TOKEN_PROGRAM_ID,
        associatedTokenProgramId = ASSOCIATED_TOKEN_PROGRAM_ID
    ): Promise<PublicKey> {
        if (!allowOwnerOffCurve && !PublicKey.isOnCurve(owner.toBuffer())) throw new Error('TokenOwnerOffCurveError')
    
        const [address] = await PublicKey.findProgramAddress(
            [owner.toBuffer(), programId.toBuffer(), mint.toBuffer()],
            associatedTokenProgramId
        )
    
        return address
    }
    

    Hope this helps others. If any one has any remarks pointers please comment.