diff --git a/clients/js/package.json b/clients/js/package.json index 9ba312fc..ef121849 100644 --- a/clients/js/package.json +++ b/clients/js/package.json @@ -27,7 +27,8 @@ "lint:fix": "eslint --fix --ext js,ts,tsx src", "format": "prettier --write .", "format:check": "prettier --check .", - "example:create-mint": "tsx src/example.ts" + "example:single-signer": "tsx src/examples/single-signer.ts", + "example:multisig": "tsx src/examples/multisig.ts" }, "publishConfig": { "access": "public", @@ -46,7 +47,8 @@ "dependencies": { "@solana-program/system": "^0.7.0", "@solana-program/token": "^0.5.1", - "@solana-program/token-2022": "^0.4.0" + "@solana-program/token-2022": "^0.4.0", + "@solana/rpc-types": "^2.1.0" }, "devDependencies": { "@eslint/js": "^9.22.0", diff --git a/clients/js/pnpm-lock.yaml b/clients/js/pnpm-lock.yaml index 0d978f98..dd1d3e24 100644 --- a/clients/js/pnpm-lock.yaml +++ b/clients/js/pnpm-lock.yaml @@ -16,6 +16,9 @@ importers: '@solana-program/token-2022': specifier: ^0.4.0 version: 0.4.0(@solana/kit@2.1.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.2)(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@6.0.5)))(@solana/sysvars@2.1.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.2)) + '@solana/rpc-types': + specifier: ^2.1.0 + version: 2.1.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.2) devDependencies: '@eslint/js': specifier: ^9.22.0 diff --git a/clients/js/src/create-mint.ts b/clients/js/src/create-mint.ts index 8e372d74..9056b823 100644 --- a/clients/js/src/create-mint.ts +++ b/clients/js/src/create-mint.ts @@ -1,20 +1,18 @@ import { Address, appendTransactionMessageInstructions, + CompilableTransactionMessage, createTransactionMessage, fetchEncodedAccount, - getSignatureFromTransaction, + GetAccountInfoApi, + GetMinimumBalanceForRentExemptionApi, IInstruction, KeyPairSigner, pipe, Rpc, - RpcSubscriptions, - sendAndConfirmTransactionFactory, setTransactionMessageFeePayerSigner, setTransactionMessageLifetimeUsingBlockhash, - signTransactionMessageWithSigners, - SolanaRpcApi, - SolanaRpcSubscriptionsApi, + TransactionMessageWithBlockhashLifetime, } from '@solana/kit'; import { getMintSize } from '@solana-program/token-2022'; import { getTransferSolInstruction } from '@solana-program/system'; @@ -24,22 +22,36 @@ import { getBackpointerSize, getCreateMintInstruction, } from './generated'; +import { Blockhash } from '@solana/rpc-types'; -export const executeCreateMint = async ({ +export interface CreateMintTxArgs { + rpc: Rpc; + blockhash: { + blockhash: Blockhash; + lastValidBlockHeight: bigint; + }; + unwrappedMint: Address; + wrappedTokenProgram: Address; + payer: KeyPairSigner; + idempotent: boolean; +} + +export interface CreateMintTxResult { + wrappedMint: Address; + backpointer: Address; + fundedWrappedMintLamports: bigint; + fundedBackpointerLamports: bigint; + tx: CompilableTransactionMessage & TransactionMessageWithBlockhashLifetime; +} + +export async function createMintTx({ rpc, - rpcSubscriptions, + blockhash, unwrappedMint, wrappedTokenProgram, payer, idempotent = false, -}: { - rpc: Rpc; - rpcSubscriptions: RpcSubscriptions; - unwrappedMint: Address; - wrappedTokenProgram: Address; - payer: KeyPairSigner; - idempotent: boolean; -}) => { +}: CreateMintTxArgs): Promise { const [wrappedMint] = await findWrappedMintPda({ unwrappedMint, wrappedTokenProgram: wrappedTokenProgram, @@ -101,26 +113,18 @@ export const executeCreateMint = async ({ }), ); - const { value: latestBlockhash } = await rpc.getLatestBlockhash().send(); - - // Build transaction const tx = pipe( createTransactionMessage({ version: 0 }), tx => setTransactionMessageFeePayerSigner(payer, tx), - tx => setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, tx), + tx => setTransactionMessageLifetimeUsingBlockhash(blockhash, tx), tx => appendTransactionMessageInstructions(instructions, tx), ); - // Send tx - const signedTransaction = await signTransactionMessageWithSigners(tx); - const sendAndConfirm = sendAndConfirmTransactionFactory({ rpc, rpcSubscriptions }); - await sendAndConfirm(signedTransaction, { commitment: 'confirmed' }); - return { wrappedMint, backpointer, - signature: getSignatureFromTransaction(signedTransaction), + tx, fundedWrappedMintLamports, fundedBackpointerLamports, }; -}; +} diff --git a/clients/js/src/example.ts b/clients/js/src/example.ts deleted file mode 100644 index bb30043e..00000000 --- a/clients/js/src/example.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { - address, - createKeyPairSignerFromBytes, - createSolanaRpc, - createSolanaRpcSubscriptions, -} from '@solana/kit'; -import { TOKEN_2022_PROGRAM_ADDRESS } from '@solana-program/token-2022'; -import { findWrappedMintPda } from './generated'; -import { executeWrap } from './wrap'; - -import { createEscrowAccount, createTokenAccount } from './utilities'; -import { executeCreateMint } from './create-mint'; -// -// Replace these consts with your own -const PRIVATE_KEY_PAIR = new Uint8Array([ - 58, 188, 194, 176, 230, 94, 253, 2, 24, 163, 198, 177, 92, 79, 213, 87, 122, 150, 216, 175, 176, - 159, 113, 144, 148, 82, 149, 249, 242, 255, 7, 1, 73, 203, 66, 98, 4, 2, 141, 236, 49, 10, 47, - 188, 93, 170, 111, 125, 44, 155, 4, 124, 48, 18, 188, 30, 158, 78, 158, 34, 44, 100, 61, 21, -]); -const UNWRAPPED_MINT_ADDRESS = address('5StBUZ2w8ShDN9iF7NkGpDNNH2wv9jK7zhArmVRpwrCt'); -const UNWRAPPED_TOKEN_ACCOUNT = address('CbuRmvG3frMoPFnsKfC2t8jTUHFjtnrKZBt2aqdqH4PG'); -const AMOUNT_TO_WRAP = 100n; - -const main = async () => { - const rpc = createSolanaRpc('http://127.0.0.1:8899'); - const rpcSubscriptions = createSolanaRpcSubscriptions('ws://127.0.0.1:8900'); - const payer = await createKeyPairSignerFromBytes(PRIVATE_KEY_PAIR); - - // Initialize the wrapped mint - const createMintResult = await executeCreateMint({ - rpc, - rpcSubscriptions, - unwrappedMint: UNWRAPPED_MINT_ADDRESS, - wrappedTokenProgram: TOKEN_2022_PROGRAM_ADDRESS, - payer, - idempotent: true, - }); - console.log('======== Create Mint Successful ========'); - console.log('Wrapped Mint:', createMintResult.wrappedMint); - console.log('Backpointer:', createMintResult.backpointer); - console.log('Funded wrapped mint lamports:', createMintResult.fundedWrappedMintLamports); - console.log('Funded backpointer lamports:', createMintResult.fundedBackpointerLamports); - console.log('Signature:', createMintResult.signature); - - // Setup accounts needed for wrap - const escrowAccount = await createEscrowAccount({ - rpc, - rpcSubscriptions, - payer, - unwrappedMint: UNWRAPPED_MINT_ADDRESS, - wrappedTokenProgram: TOKEN_2022_PROGRAM_ADDRESS, - }); - - const [wrappedMint] = await findWrappedMintPda({ - unwrappedMint: UNWRAPPED_MINT_ADDRESS, - wrappedTokenProgram: TOKEN_2022_PROGRAM_ADDRESS, - }); - const recipientTokenAccount = await createTokenAccount({ - rpc, - rpcSubscriptions, - payer, - mint: wrappedMint, - tokenProgram: TOKEN_2022_PROGRAM_ADDRESS, - owner: payer.address, - }); - - const wrapResult = await executeWrap({ - rpc, - rpcSubscriptions, - payer, - unwrappedTokenAccount: UNWRAPPED_TOKEN_ACCOUNT, - escrowAccount, - wrappedTokenProgram: TOKEN_2022_PROGRAM_ADDRESS, - amount: AMOUNT_TO_WRAP, - unwrappedMint: UNWRAPPED_MINT_ADDRESS, - recipientTokenAccount, - }); - - console.log('======== Wrap Successful ========'); - console.log('Wrap amount:', wrapResult.amount); - console.log('Recipient account:', wrapResult.recipientWrappedTokenAccount); - console.log('Escrow Account:', wrapResult.escrowAccount); - console.log('Signature:', wrapResult.signature); -}; - -void main(); diff --git a/clients/js/src/examples/multisig.ts b/clients/js/src/examples/multisig.ts new file mode 100644 index 00000000..8039f0cb --- /dev/null +++ b/clients/js/src/examples/multisig.ts @@ -0,0 +1,183 @@ +import { + address, + createKeyPairSignerFromBytes, + createNoopSigner, + createSolanaRpc, + createSolanaRpcSubscriptions, + getBase58Decoder, + getSignatureFromTransaction, + partiallySignTransactionMessageWithSigners, + sendAndConfirmTransactionFactory, + signTransactionMessageWithSigners, +} from '@solana/kit'; +import { TOKEN_2022_PROGRAM_ADDRESS } from '@solana-program/token-2022'; +import { findWrappedMintAuthorityPda, findWrappedMintPda } from '../generated'; +import { combinedMultisigWrapTx, multisigOfflineSignWrapTx } from '../wrap'; +import { createEscrowAccountTx, createTokenAccountTx, getOwnerFromAccount } from '../utilities'; +import { createMintTx } from '../create-mint'; + +// Replace these consts with your own +const PAYER_KEYPAIR_BYTES = new Uint8Array([ + 242, 30, 38, 177, 152, 71, 235, 193, 93, 30, 119, 131, 42, 186, 202, 7, 45, 250, 126, 135, 107, + 137, 38, 91, 202, 212, 12, 8, 154, 213, 163, 200, 23, 237, 17, 163, 3, 135, 34, 126, 235, 146, + 251, 18, 199, 101, 153, 249, 134, 88, 219, 68, 167, 136, 234, 195, 12, 34, 184, 85, 234, 25, 125, + 94, +]); + +// Create using CLI: spl-token create-multisig 2 $SIGNER_1_PUBKEY $SIGNER_2_PUBKEY +const MULTISIG_PUBKEY = address('2XBevFsu4pnZpB9PewYKAJHNyx9dFQf3MaiGBszF5fm8'); +const SIGNER_A_KEYPAIR_BYTES = new Uint8Array([ + 210, 190, 232, 169, 113, 107, 195, 87, 14, 9, 125, 106, 41, 174, 131, 9, 29, 144, 95, 134, 68, + 123, 80, 215, 194, 30, 170, 140, 33, 175, 69, 126, 201, 176, 240, 30, 173, 145, 185, 162, 231, + 196, 71, 236, 233, 153, 42, 243, 146, 82, 70, 153, 129, 194, 156, 110, 84, 18, 71, 143, 38, 244, + 232, 58, +]); +const SIGNER_B_KEYPAIR_BYTES = new Uint8Array([ + 37, 161, 191, 225, 59, 192, 226, 154, 168, 4, 189, 155, 235, 240, 187, 210, 230, 176, 133, 163, 6, + 132, 229, 129, 10, 9, 67, 88, 215, 124, 195, 243, 189, 178, 12, 18, 216, 91, 154, 193, 75, 164, + 71, 224, 106, 148, 225, 156, 124, 241, 250, 51, 27, 8, 37, 111, 60, 187, 219, 161, 55, 42, 129, + 236, +]); + +const UNWRAPPED_MINT_ADDRESS = address('F2qGWupzMUQnGfX8e25XZps8d9AGdVde8hLQT2pxsb4M'); +const UNWRAPPED_TOKEN_ACCOUNT = address('94Y9pxekEm59b67PQQwvjb7wbwz689wDZ3dAwhCtJpPS'); // Must be owned by multisig account +const AMOUNT_TO_WRAP = 100n; + +const main = async () => { + const rpc = createSolanaRpc('http://127.0.0.1:8899'); + const rpcSubscriptions = createSolanaRpcSubscriptions('ws://127.0.0.1:8900'); + const sendAndConfirm = sendAndConfirmTransactionFactory({ rpc, rpcSubscriptions }); + + const payer = await createKeyPairSignerFromBytes(PAYER_KEYPAIR_BYTES); + const { value: blockhash } = await rpc.getLatestBlockhash().send(); + + // Initialize the wrapped mint + const createMintMessage = await createMintTx({ + rpc, + blockhash, + unwrappedMint: UNWRAPPED_MINT_ADDRESS, + wrappedTokenProgram: TOKEN_2022_PROGRAM_ADDRESS, + payer, + idempotent: true, + }); + const signedCreateMintTx = await signTransactionMessageWithSigners(createMintMessage.tx); + await sendAndConfirm(signedCreateMintTx, { commitment: 'confirmed' }); + const createMintSignature = getSignatureFromTransaction(signedCreateMintTx); + + console.log('======== Create Mint Successful ========'); + console.log('Wrapped Mint:', createMintMessage.wrappedMint); + console.log('Backpointer:', createMintMessage.backpointer); + console.log('Funded wrapped mint lamports:', createMintMessage.fundedWrappedMintLamports); + console.log('Funded backpointer lamports:', createMintMessage.fundedBackpointerLamports); + console.log('Signature:', createMintSignature); + + // === Setup accounts needed for wrap === + + // Create escrow account that with hold unwrapped tokens + const createEscrowMessage = await createEscrowAccountTx({ + rpc, + blockhash, + payer, + unwrappedMint: UNWRAPPED_MINT_ADDRESS, + wrappedTokenProgram: TOKEN_2022_PROGRAM_ADDRESS, + }); + const signedCreateEscrowTx = await signTransactionMessageWithSigners(createEscrowMessage.tx); + await sendAndConfirm(signedCreateEscrowTx, { commitment: 'confirmed' }); + + // Create recipient account where wrapped tokens will be minted to + const [wrappedMint] = await findWrappedMintPda({ + unwrappedMint: UNWRAPPED_MINT_ADDRESS, + wrappedTokenProgram: TOKEN_2022_PROGRAM_ADDRESS, + }); + const recipientTokenAccountMessage = await createTokenAccountTx({ + rpc, + blockhash, + payer, + mint: wrappedMint, + tokenProgram: TOKEN_2022_PROGRAM_ADDRESS, + owner: payer.address, + }); + const signedRecipientAccountTx = await signTransactionMessageWithSigners( + recipientTokenAccountMessage.tx, + ); + await sendAndConfirm(signedRecipientAccountTx, { commitment: 'confirmed' }); + + const unwrappedTokenProgram = await getOwnerFromAccount(rpc, UNWRAPPED_TOKEN_ACCOUNT); + const [wrappedMintAuthority] = await findWrappedMintAuthorityPda({ wrappedMint }); + + const signerA = await createKeyPairSignerFromBytes(SIGNER_A_KEYPAIR_BYTES); + const signerB = await createKeyPairSignerFromBytes(SIGNER_B_KEYPAIR_BYTES); + + // Two signers and the payer sign the transaction independently + + const wrapTxA = multisigOfflineSignWrapTx({ + payer: createNoopSigner(payer.address), + unwrappedTokenAccount: UNWRAPPED_TOKEN_ACCOUNT, + escrowAccount: createEscrowMessage.keyPair.address, + wrappedTokenProgram: TOKEN_2022_PROGRAM_ADDRESS, + amount: AMOUNT_TO_WRAP, + unwrappedMint: UNWRAPPED_MINT_ADDRESS, + recipientWrappedTokenAccount: recipientTokenAccountMessage.keyPair.address, + transferAuthority: MULTISIG_PUBKEY, + wrappedMint, + wrappedMintAuthority, + unwrappedTokenProgram, + multiSigners: [signerA, createNoopSigner(signerB.address)], + blockhash, + }); + const signedWrapTxA = await partiallySignTransactionMessageWithSigners(wrapTxA); + + const wrapTxB = multisigOfflineSignWrapTx({ + payer: createNoopSigner(payer.address), + unwrappedTokenAccount: UNWRAPPED_TOKEN_ACCOUNT, + escrowAccount: createEscrowMessage.keyPair.address, + wrappedTokenProgram: TOKEN_2022_PROGRAM_ADDRESS, + amount: AMOUNT_TO_WRAP, + unwrappedMint: UNWRAPPED_MINT_ADDRESS, + recipientWrappedTokenAccount: recipientTokenAccountMessage.keyPair.address, + transferAuthority: MULTISIG_PUBKEY, + wrappedMint, + wrappedMintAuthority, + unwrappedTokenProgram, + multiSigners: [createNoopSigner(signerA.address), signerB], + blockhash, + }); + const signedWrapTxB = await partiallySignTransactionMessageWithSigners(wrapTxB); + + const wrapTxC = multisigOfflineSignWrapTx({ + payer, + unwrappedTokenAccount: UNWRAPPED_TOKEN_ACCOUNT, + escrowAccount: createEscrowMessage.keyPair.address, + wrappedTokenProgram: TOKEN_2022_PROGRAM_ADDRESS, + amount: AMOUNT_TO_WRAP, + unwrappedMint: UNWRAPPED_MINT_ADDRESS, + recipientWrappedTokenAccount: recipientTokenAccountMessage.keyPair.address, + transferAuthority: MULTISIG_PUBKEY, + wrappedMint, + wrappedMintAuthority, + unwrappedTokenProgram, + multiSigners: [createNoopSigner(signerA.address), createNoopSigner(signerB.address)], + blockhash, + }); + const signedWrapTxC = await partiallySignTransactionMessageWithSigners(wrapTxC); + + // Lastly, all signatures are combined together and broadcast + + const combinedTx = combinedMultisigWrapTx({ + signedTxs: [signedWrapTxA, signedWrapTxB, signedWrapTxC], + blockhash, + }); + await sendAndConfirm(combinedTx, { commitment: 'confirmed' }); + + console.log('======== Confirmed Multisig Tx ✅ ========'); + for (const [pubkey, signature] of Object.entries(combinedTx.signatures)) { + if (signature) { + const base58Sig = getBase58Decoder().decode(signature); + console.log(`pubkey: ${pubkey}`); + console.log(`signature: ${base58Sig}`); + console.log('-----'); + } + } +}; + +void main(); diff --git a/clients/js/src/examples/single-signer.ts b/clients/js/src/examples/single-signer.ts new file mode 100644 index 00000000..e508b643 --- /dev/null +++ b/clients/js/src/examples/single-signer.ts @@ -0,0 +1,111 @@ +import { + address, + createKeyPairSignerFromBytes, + createSolanaRpc, + createSolanaRpcSubscriptions, + getSignatureFromTransaction, + sendAndConfirmTransactionFactory, + signTransactionMessageWithSigners, +} from '@solana/kit'; +import { TOKEN_2022_PROGRAM_ADDRESS } from '@solana-program/token-2022'; +import { findWrappedMintPda } from '../generated'; +import { singleSignerWrapTx } from '../wrap'; + +import { createEscrowAccountTx, createTokenAccountTx } from '../utilities'; +import { createMintTx } from '../create-mint'; + +// Replace these consts with your own +const PRIVATE_KEY_PAIR = new Uint8Array([ + 242, 30, 38, 177, 152, 71, 235, 193, 93, 30, 119, 131, 42, 186, 202, 7, 45, 250, 126, 135, 107, + 137, 38, 91, 202, 212, 12, 8, 154, 213, 163, 200, 23, 237, 17, 163, 3, 135, 34, 126, 235, 146, + 251, 18, 199, 101, 153, 249, 134, 88, 219, 68, 167, 136, 234, 195, 12, 34, 184, 85, 234, 25, 125, + 94, +]); +const UNWRAPPED_MINT_ADDRESS = address('FAbYm8kdDsyc6csvTXPMBwCJDjTVkZcvrnyVVTSF74hU'); +const UNWRAPPED_TOKEN_ACCOUNT = address('4dSPDdFuTbKTuJDDtTd8SUdbH6QY42hpTPRi6RRzzsPF'); +const AMOUNT_TO_WRAP = 100n; + +const main = async () => { + const rpc = createSolanaRpc('http://127.0.0.1:8899'); + const rpcSubscriptions = createSolanaRpcSubscriptions('ws://127.0.0.1:8900'); + const sendAndConfirm = sendAndConfirmTransactionFactory({ rpc, rpcSubscriptions }); + + const payer = await createKeyPairSignerFromBytes(PRIVATE_KEY_PAIR); + const { value: blockhash } = await rpc.getLatestBlockhash().send(); + + // Initialize the wrapped mint + const createMintMessage = await createMintTx({ + rpc, + blockhash, + unwrappedMint: UNWRAPPED_MINT_ADDRESS, + wrappedTokenProgram: TOKEN_2022_PROGRAM_ADDRESS, + payer, + idempotent: true, + }); + const signedCreateMintTx = await signTransactionMessageWithSigners(createMintMessage.tx); + await sendAndConfirm(signedCreateMintTx, { commitment: 'confirmed' }); + const createMintSignature = getSignatureFromTransaction(signedCreateMintTx); + + console.log('======== Create Mint Successful ========'); + console.log('Wrapped Mint:', createMintMessage.wrappedMint); + console.log('Backpointer:', createMintMessage.backpointer); + console.log('Funded wrapped mint lamports:', createMintMessage.fundedWrappedMintLamports); + console.log('Funded backpointer lamports:', createMintMessage.fundedBackpointerLamports); + console.log('Signature:', createMintSignature); + + // === Setup accounts needed for wrap === + + // Create escrow account that with hold unwrapped tokens + const createEscrowMessage = await createEscrowAccountTx({ + rpc, + blockhash, + payer, + unwrappedMint: UNWRAPPED_MINT_ADDRESS, + wrappedTokenProgram: TOKEN_2022_PROGRAM_ADDRESS, + }); + const signedCreateEscrowTx = await signTransactionMessageWithSigners(createEscrowMessage.tx); + await sendAndConfirm(signedCreateEscrowTx, { commitment: 'confirmed' }); + + // Create recipient account where wrapped tokens will be minted to + const [wrappedMint] = await findWrappedMintPda({ + unwrappedMint: UNWRAPPED_MINT_ADDRESS, + wrappedTokenProgram: TOKEN_2022_PROGRAM_ADDRESS, + }); + const recipientTokenAccountMessage = await createTokenAccountTx({ + rpc, + blockhash, + payer, + mint: wrappedMint, + tokenProgram: TOKEN_2022_PROGRAM_ADDRESS, + owner: payer.address, + }); + const signedRecipientAccountTx = await signTransactionMessageWithSigners( + recipientTokenAccountMessage.tx, + ); + await sendAndConfirm(signedRecipientAccountTx, { commitment: 'confirmed' }); + + // Execute wrap + const wrapMessage = await singleSignerWrapTx({ + rpc, + blockhash, + payer, + unwrappedTokenAccount: UNWRAPPED_TOKEN_ACCOUNT, + escrowAccount: createEscrowMessage.keyPair.address, + wrappedTokenProgram: TOKEN_2022_PROGRAM_ADDRESS, + amount: AMOUNT_TO_WRAP, + unwrappedMint: UNWRAPPED_MINT_ADDRESS, + recipientWrappedTokenAccount: recipientTokenAccountMessage.keyPair.address, + }); + + const signedWrapTx = await signTransactionMessageWithSigners(wrapMessage.tx); + await sendAndConfirm(signedWrapTx, { commitment: 'confirmed' }); + const signature = getSignatureFromTransaction(signedWrapTx); + + console.log('======== Wrap Successful ========'); + console.log('Wrap amount:', wrapMessage.amount); + console.log('Recipient account:', wrapMessage.recipientWrappedTokenAccount); + console.log('Escrow Account:', wrapMessage.escrowAccount); + console.log('Signature:', signature); +}; + +void main(); diff --git a/clients/js/src/index.ts b/clients/js/src/index.ts index a2e5d373..5e015327 100644 --- a/clients/js/src/index.ts +++ b/clients/js/src/index.ts @@ -1,5 +1,17 @@ export * from './generated'; -export { executeCreateMint } from './create-mint'; -export { executeWrap } from './wrap'; -export { createEscrowAccount } from './utilities'; +export { createMintTx, type CreateMintTxArgs, type CreateMintTxResult } from './create-mint'; +export { + singleSignerWrapTx, + type SingleSignerWrapArgs, + type SingleSignerWrapResult, + multisigOfflineSignWrapTx, + type TxBuilderArgsWithMultiSigners, + combinedMultisigWrapTx, + type MultiSigBroadcastArgs, +} from './wrap'; +export { + createEscrowAccountTx, + type CreateEscrowAccountTxArgs, + type CreateEscrowAccountTxResult, +} from './utilities'; diff --git a/clients/js/src/utilities.ts b/clients/js/src/utilities.ts index ba72d43b..10bf152a 100644 --- a/clients/js/src/utilities.ts +++ b/clients/js/src/utilities.ts @@ -2,18 +2,17 @@ import { findWrappedMintAuthorityPda, findWrappedMintPda } from './generated'; import { Address, appendTransactionMessageInstructions, + CompilableTransactionMessage, createTransactionMessage, generateKeyPairSigner, + GetAccountInfoApi, + GetMinimumBalanceForRentExemptionApi, KeyPairSigner, pipe, Rpc, - RpcSubscriptions, - sendAndConfirmTransactionFactory, setTransactionMessageFeePayerSigner, setTransactionMessageLifetimeUsingBlockhash, - signTransactionMessageWithSigners, - SolanaRpcApi, - SolanaRpcSubscriptionsApi, + TransactionMessageWithBlockhashLifetime, } from '@solana/kit'; import { getCreateAccountInstruction } from '@solana-program/system'; import { @@ -24,32 +23,35 @@ import { getInitializeAccountInstruction as initializeToken2022, TOKEN_2022_PROGRAM_ADDRESS, } from '@solana-program/token-2022'; +import { Blockhash } from '@solana/rpc-types'; -const getInitializeTokenFn = (tokenProgram: Address) => { +function getInitializeTokenFn(tokenProgram: Address) { if (tokenProgram === TOKEN_PROGRAM_ADDRESS) return initializeToken; if (tokenProgram === TOKEN_2022_PROGRAM_ADDRESS) return initializeToken2022; throw new Error(`${tokenProgram} is not a valid token program.`); -}; +} -export const createTokenAccount = async ({ +export async function createTokenAccountTx({ rpc, - rpcSubscriptions, + blockhash, payer, mint, owner, tokenProgram, }: { - rpc: Rpc; - rpcSubscriptions: RpcSubscriptions; + rpc: Rpc; + blockhash: { + blockhash: Blockhash; + lastValidBlockHeight: bigint; + }; payer: KeyPairSigner; mint: Address; owner: Address; tokenProgram: Address; -}): Promise
=> { - const [keyPair, lamports, { value: latestBlockhash }] = await Promise.all([ +}) { + const [keyPair, lamports] = await Promise.all([ generateKeyPairSigner(), rpc.getMinimumBalanceForRentExemption(165n).send(), - rpc.getLatestBlockhash().send(), ]); const createAccountIx = getCreateAccountInstruction({ @@ -70,38 +72,60 @@ export const createTokenAccount = async ({ const tx = pipe( createTransactionMessage({ version: 0 }), tx => setTransactionMessageFeePayerSigner(payer, tx), - tx => setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, tx), + tx => setTransactionMessageLifetimeUsingBlockhash(blockhash, tx), tx => appendTransactionMessageInstructions([createAccountIx, initializeAccountIx], tx), ); - const signedTx = await signTransactionMessageWithSigners(tx); - const sendAndConfirm = sendAndConfirmTransactionFactory({ rpc, rpcSubscriptions }); - await sendAndConfirm(signedTx, { commitment: 'confirmed' }); + return { + tx, + keyPair, + }; +} - return keyPair.address; -}; +export interface CreateEscrowAccountTxArgs { + rpc: Rpc; + blockhash: { + blockhash: Blockhash; + lastValidBlockHeight: bigint; + }; + payer: KeyPairSigner; + unwrappedMint: Address; + wrappedTokenProgram: Address; +} -export const createEscrowAccount = async ({ +export interface CreateEscrowAccountTxResult { + tx: CompilableTransactionMessage & TransactionMessageWithBlockhashLifetime; + keyPair: KeyPairSigner; +} + +export async function createEscrowAccountTx({ rpc, - rpcSubscriptions, + blockhash, payer, unwrappedMint, wrappedTokenProgram, -}: { - rpc: Rpc; - rpcSubscriptions: RpcSubscriptions; - payer: KeyPairSigner; - unwrappedMint: Address; - wrappedTokenProgram: Address; -}) => { +}: CreateEscrowAccountTxArgs): Promise { const [wrappedMint] = await findWrappedMintPda({ unwrappedMint, wrappedTokenProgram }); const [wrappedMintAuthority] = await findWrappedMintAuthorityPda({ wrappedMint }); - return createTokenAccount({ + const unwrappedTokenProgram = await getOwnerFromAccount(rpc, unwrappedMint); + + return createTokenAccountTx({ rpc, - rpcSubscriptions, + blockhash, payer, mint: unwrappedMint, owner: wrappedMintAuthority, - tokenProgram: TOKEN_PROGRAM_ADDRESS, + tokenProgram: unwrappedTokenProgram, }); -}; +} + +export async function getOwnerFromAccount( + rpc: Rpc, + accountAddress: Address, +): Promise
{ + const accountInfo = await rpc.getAccountInfo(accountAddress, { encoding: 'base64' }).send(); + if (!accountInfo.value) { + throw new Error(`Account ${accountAddress} not found.`); + } + return accountInfo.value.owner; +} diff --git a/clients/js/src/wrap.ts b/clients/js/src/wrap.ts index 6162fe6b..74974eb2 100644 --- a/clients/js/src/wrap.ts +++ b/clients/js/src/wrap.ts @@ -1,32 +1,35 @@ import { Address, appendTransactionMessageInstructions, + assertTransactionIsFullySigned, + containsBytes, createTransactionMessage, fetchEncodedAccount, - getSignatureFromTransaction, - KeyPairSigner, + GetAccountInfoApi, pipe, Rpc, - RpcSubscriptions, - sendAndConfirmTransactionFactory, setTransactionMessageFeePayerSigner, setTransactionMessageLifetimeUsingBlockhash, - signTransactionMessageWithSigners, - SolanaRpcApi, - SolanaRpcSubscriptionsApi, + SignatureBytes, + Transaction, + TransactionMessageWithBlockhashLifetime, TransactionSigner, + TransactionWithBlockhashLifetime, + CompilableTransactionMessage, + FullySignedTransaction, } from '@solana/kit'; -import { getTokenDecoder } from '@solana-program/token-2022'; -import { findAssociatedTokenPda } from '@solana-program/token-2022'; +import { findAssociatedTokenPda, getTokenDecoder } from '@solana-program/token-2022'; import { findWrappedMintAuthorityPda, findWrappedMintPda, getWrapInstruction, WrapInput, } from './generated'; +import { Blockhash } from '@solana/rpc-types'; +import { getOwnerFromAccount } from './utilities'; const getMintFromTokenAccount = async ( - rpc: Rpc, + rpc: Rpc, tokenAccountAddress: Address, ): Promise
=> { const account = await fetchEncodedAccount(rpc, tokenAccountAddress); @@ -36,35 +39,155 @@ const getMintFromTokenAccount = async ( return getTokenDecoder().decode(account.data).mint; }; -const getOwnerFromAccount = async ( - rpc: Rpc, - accountAddress: Address, -): Promise
=> { - const accountInfo = await rpc.getAccountInfo(accountAddress, { encoding: 'base64' }).send(); - if (!accountInfo.value) { - throw new Error(`Account ${accountAddress} not found.`); +interface TxBuilderArgs { + payer: TransactionSigner; + unwrappedTokenAccount: Address; + escrowAccount: Address; + wrappedTokenProgram: Address; + amount: bigint | number; + wrappedMint: Address; + wrappedMintAuthority: Address; + blockhash: { + blockhash: Blockhash; + lastValidBlockHeight: bigint; + }; + transferAuthority: Address | TransactionSigner; + unwrappedMint: Address; + recipientWrappedTokenAccount: Address; + unwrappedTokenProgram: Address; + multiSigners?: TransactionSigner[]; +} + +export interface TxBuilderArgsWithMultiSigners extends TxBuilderArgs { + multiSigners: TransactionSigner[]; +} + +// Used to collect signatures +export function multisigOfflineSignWrapTx( + args: TxBuilderArgsWithMultiSigners, +): CompilableTransactionMessage & TransactionMessageWithBlockhashLifetime { + return buildWrapTransaction(args); +} + +function messageBytesEqual(results: (Transaction & TransactionWithBlockhashLifetime)[]): boolean { + // If array has only one element, return true + if (results.length === 1) { + return true; + } + + // Use the first result as reference + const reference = results[0]; + if (!reference) throw new Error('No transactions in input'); + + // Compare each result with the reference + for (const current of results) { + const sameLength = reference.messageBytes.length === current.messageBytes.length; + const sameBytes = containsBytes(reference.messageBytes, current.messageBytes, 0); + + if (!sameLength || !sameBytes) { + return false; + } + } + + return true; +} + +function combineSignatures( + signedTxs: (Transaction & TransactionWithBlockhashLifetime)[], +): Record { + // Step 1: Determine the canonical signer order from the first signed transaction. + // Insertion order is the way to re-create this. Without it, verification will fail. + const firstSignedTx = signedTxs[0]; + if (!firstSignedTx) { + throw new Error('No signed transactions provided'); + } + + const allSignatures: Record = {}; + + // Step 1: Insert a null signature for each signer, maintaining the order of the signatures from the first signed transaction + for (const pubkey of Object.keys(firstSignedTx.signatures)) { + allSignatures[pubkey] = null; + } + + // Step 2: Gather all signatures from all transactions + for (const signedTx of signedTxs) { + for (const [address, signature] of Object.entries(signedTx.signatures)) { + if (signature) { + // only store non-null signers + allSignatures[address] = signature; + } + } } - return accountInfo.value.owner; -}; -export interface ExecuteWrapArgs { - rpc: Rpc; - rpcSubscriptions: RpcSubscriptions; - payer: KeyPairSigner; // Fee payer and default transfer authority + // Step 3: Assert all signatures are set + const missingSigners: string[] = []; + for (const [pubkey, signature] of Object.entries(allSignatures)) { + if (signature === null) { + missingSigners.push(pubkey); + } + } + if (missingSigners.length > 0) { + throw new Error(`Missing signatures for: ${missingSigners.join(', ')}`); + } + + return allSignatures as Record; +} + +export interface MultiSigBroadcastArgs { + signedTxs: (Transaction & TransactionWithBlockhashLifetime)[]; + blockhash: { + blockhash: Blockhash; + lastValidBlockHeight: bigint; + }; +} + +// Combines, validates, and broadcasts outputs of multisigOfflineSignWrap() +export function combinedMultisigWrapTx({ + signedTxs, + blockhash, +}: MultiSigBroadcastArgs): FullySignedTransaction & TransactionWithBlockhashLifetime { + const messagesEqual = messageBytesEqual(signedTxs); + if (!messagesEqual) throw new Error('Messages are not all the same'); + if (!signedTxs[0]) throw new Error('No signed transactions provided'); + + const tx = { + messageBytes: signedTxs[0].messageBytes, + signatures: combineSignatures(signedTxs), + lifetimeConstraint: blockhash, + }; + + assertTransactionIsFullySigned(tx); + + return tx; +} + +export interface SingleSignerWrapArgs { + rpc: Rpc; + blockhash: { + blockhash: Blockhash; + lastValidBlockHeight: bigint; + }; + payer: TransactionSigner; // Fee payer and default transfer authority unwrappedTokenAccount: Address; escrowAccount: Address; wrappedTokenProgram: Address; amount: bigint | number; transferAuthority?: Address | TransactionSigner; // Defaults to payer if not provided unwrappedMint?: Address; // Will fetch from unwrappedTokenAccount if not provided - recipientTokenAccount?: Address; // Defaults to payer's ATA if not provided + recipientWrappedTokenAccount?: Address; // Defaults to payer's ATA if not provided unwrappedTokenProgram?: Address; // Will fetch from unwrappedTokenAccount owner if not provided - multiSigners?: TransactionSigner[]; // For multisig transfer authority } -export const executeWrap = async ({ +export interface SingleSignerWrapResult { + tx: CompilableTransactionMessage & TransactionMessageWithBlockhashLifetime; + recipientWrappedTokenAccount: Address; + escrowAccount: Address; + amount: bigint; +} + +export async function singleSignerWrapTx({ rpc, - rpcSubscriptions, + blockhash, payer, unwrappedTokenAccount, escrowAccount, @@ -72,11 +195,70 @@ export const executeWrap = async ({ amount, transferAuthority: inputTransferAuthority, unwrappedMint: inputUnwrappedMint, - recipientTokenAccount: inputRecipientTokenAccount, + recipientWrappedTokenAccount: inputRecipientTokenAccount, unwrappedTokenProgram: inputUnwrappedTokenProgram, - multiSigners = [], -}: ExecuteWrapArgs) => { - // --- 1. Resolve Addresses --- +}: SingleSignerWrapArgs): Promise { + const { + unwrappedMint, + unwrappedTokenProgram, + wrappedMint, + wrappedMintAuthority, + recipientWrappedTokenAccount, + transferAuthority, + } = await resolveAddrs({ + rpc, + payer, + inputTransferAuthority, + inputUnwrappedMint, + unwrappedTokenAccount, + inputUnwrappedTokenProgram, + wrappedTokenProgram, + inputRecipientTokenAccount, + }); + + const tx = buildWrapTransaction({ + blockhash, + payer, + unwrappedTokenAccount, + escrowAccount, + wrappedTokenProgram, + amount, + transferAuthority, + unwrappedMint, + wrappedMint, + wrappedMintAuthority, + recipientWrappedTokenAccount, + unwrappedTokenProgram, + }); + + return { + tx, + recipientWrappedTokenAccount, + escrowAccount, + amount: BigInt(amount), + }; +} + +// Meant to handle all of the potential default values +async function resolveAddrs({ + rpc, + payer, + unwrappedTokenAccount, + wrappedTokenProgram, + inputTransferAuthority, + inputUnwrappedMint, + inputRecipientTokenAccount, + inputUnwrappedTokenProgram, +}: { + rpc: Rpc; + payer: TransactionSigner; + unwrappedTokenAccount: Address; + wrappedTokenProgram: Address; + inputTransferAuthority?: Address | TransactionSigner; + inputUnwrappedMint?: Address; + inputRecipientTokenAccount?: Address; + inputUnwrappedTokenProgram?: Address; +}) { const unwrappedMint = inputUnwrappedMint ?? (await getMintFromTokenAccount(rpc, unwrappedTokenAccount)); const unwrappedTokenProgram = @@ -93,8 +275,33 @@ export const executeWrap = async ({ }) )[0]; - // --- 2. Create the Instruction --- const transferAuthority = inputTransferAuthority ?? payer; + + return { + transferAuthority, + unwrappedMint, + unwrappedTokenProgram, + wrappedMint, + wrappedMintAuthority, + recipientWrappedTokenAccount, + }; +} + +function buildWrapTransaction({ + payer, + unwrappedTokenAccount, + escrowAccount, + wrappedTokenProgram, + amount, + transferAuthority, + unwrappedMint, + recipientWrappedTokenAccount, + unwrappedTokenProgram, + wrappedMint, + wrappedMintAuthority, + blockhash, + multiSigners = [], +}: TxBuilderArgs): CompilableTransactionMessage & TransactionMessageWithBlockhashLifetime { const wrapInstructionInput: WrapInput = { recipientWrappedTokenAccount, wrappedMint, @@ -111,25 +318,10 @@ export const executeWrap = async ({ const wrapInstruction = getWrapInstruction(wrapInstructionInput); - // --- 3. Build & Sign Transaction --- - const { value: latestBlockhash } = await rpc.getLatestBlockhash().send(); - const tx = pipe( + return pipe( createTransactionMessage({ version: 0 }), tx => setTransactionMessageFeePayerSigner(payer, tx), - tx => setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, tx), + tx => setTransactionMessageLifetimeUsingBlockhash(blockhash, tx), tx => appendTransactionMessageInstructions([wrapInstruction], tx), ); - const signedTransaction = await signTransactionMessageWithSigners(tx); - - // --- 4. Send and Confirm Transaction --- - const signature = getSignatureFromTransaction(signedTransaction); - const sendAndConfirm = sendAndConfirmTransactionFactory({ rpc, rpcSubscriptions }); - await sendAndConfirm(signedTransaction, { commitment: 'confirmed' }); - - return { - recipientWrappedTokenAccount, - escrowAccount, - amount: BigInt(amount), - signature, - }; -}; +}