diff --git a/src/commands/bg/collection/create.ts b/src/commands/bg/collection/create.ts index 16e35f5..5b0b407 100644 --- a/src/commands/bg/collection/create.ts +++ b/src/commands/bg/collection/create.ts @@ -67,7 +67,7 @@ The Bubblegum V2 plugin is required for collections that will contain compressed basisPoints: Math.round(flags.royalties * 100), creators: [ { - address: this.context.signer.publicKey, + address: umi.identity.publicKey, percentage: 100, }, ], @@ -104,7 +104,7 @@ Collection: ${collectionAddress} Name: ${flags.name} URI: ${flags.uri} Royalties: ${flags.royalties}% -Update Authority: ${this.context.signer.publicKey} +Update Authority: ${umi.identity.publicKey} Plugins: ${pluginsList} Transaction: ${signature} @@ -123,7 +123,7 @@ Use it with: mplx bg nft create --collection ${collectionAddress} name: flags.name, uri: flags.uri, royalties: flags.royalties, - updateAuthority: this.context.signer.publicKey.toString(), + updateAuthority: umi.identity.publicKey.toString(), } } catch (error) { spinner.fail('Failed to create collection') diff --git a/src/commands/bg/nft/burn.ts b/src/commands/bg/nft/burn.ts index c47bcd8..716e406 100644 --- a/src/commands/bg/nft/burn.ts +++ b/src/commands/bg/nft/burn.ts @@ -58,7 +58,7 @@ Note: The current owner (or their delegate) must be the signer. This action is i const leafOwner = assetWithProof.leafOwner const leafDelegate = assetWithProof.leafDelegate - const signerKey = this.context.signer.publicKey.toString() + const signerKey = umi.identity.publicKey.toString() const ownerKey = leafOwner.toString() const delegateKey = leafDelegate ? leafDelegate.toString() : null diff --git a/src/commands/bg/nft/create.ts b/src/commands/bg/nft/create.ts index 08b69fa..c2cdd07 100644 --- a/src/commands/bg/nft/create.ts +++ b/src/commands/bg/nft/create.ts @@ -225,7 +225,7 @@ Note: Bubblegum V2 uses Metaplex Core collections. To create a Core collection: private resolveOwner(ownerFlag?: string): PublicKey { if (!ownerFlag) { - return this.context.signer.publicKey + return this.context.umi.identity.publicKey } return this.parsePublicKey('owner', ownerFlag) @@ -321,7 +321,7 @@ Note: Bubblegum V2 uses Metaplex Core collections. To create a Core collection: collection: collectionPubkey, creators: [ { - address: this.context.signer.publicKey, + address: this.context.umi.identity.publicKey, verified: true, share: 100, }, diff --git a/src/commands/bg/nft/transfer.ts b/src/commands/bg/nft/transfer.ts index 33bfdec..f9d442b 100644 --- a/src/commands/bg/nft/transfer.ts +++ b/src/commands/bg/nft/transfer.ts @@ -70,7 +70,7 @@ Note: The current owner (or their delegate) must be the signer.` const leafOwner = assetWithProof.leafOwner const leafDelegate = assetWithProof.leafDelegate - const signerKey = this.context.signer.publicKey.toString() + const signerKey = umi.identity.publicKey.toString() const ownerKey = leafOwner.toString() const delegateKey = leafDelegate ? leafDelegate.toString() : null diff --git a/src/commands/cm/create.ts b/src/commands/cm/create.ts index 3350ae6..c40590d 100644 --- a/src/commands/cm/create.ts +++ b/src/commands/cm/create.ts @@ -404,11 +404,12 @@ export default class CmCreate extends TransactionCommand { // Collection creation onchain const collectionCreationSpinner = ora('🏭 Creating collection onchain...').start() - await createCollection(umi, { + const collectionTx = createCollection(umi, { collection, name: collectionJson.name, uri: collectionJson.uri, - }).sendAndConfirm(umi, { send: { commitment: 'finalized' }, confirm: { commitment: 'finalized' } }) + }) + await umiSendAndConfirmTransaction(umi, collectionTx, { commitment: 'finalized' }) const collectionRes = await fetchCollection(umi, collection.publicKey) @@ -441,7 +442,7 @@ export default class CmCreate extends TransactionCommand { guards: parsedGuards.guards, groups: parsedGuards.groups, }); - await tx.sendAndConfirm(umi); + await umiSendAndConfirmTransaction(umi, tx); candyMachineCreatorSpinner.succeed(`Candy machine created with guards - ${candyMachine.publicKey}`) } else { // Create candy machine without candy guard (authority-only minting) @@ -453,7 +454,7 @@ export default class CmCreate extends TransactionCommand { isMutable: candyMachineConfig.config.isMutable, ...getConfigLineSettings(candyMachineConfig), }); - await tx.sendAndConfirm(umi); + await umiSendAndConfirmTransaction(umi, tx); candyMachineCreatorSpinner.succeed(`Candy machine created (authority-only minting) - ${candyMachine.publicKey}`) } } catch (error) { diff --git a/src/commands/cm/withdraw.ts b/src/commands/cm/withdraw.ts index f405810..abb1698 100644 --- a/src/commands/cm/withdraw.ts +++ b/src/commands/cm/withdraw.ts @@ -7,6 +7,7 @@ import { generateExplorerUrl } from '../../explorers.js' import { terminalColors } from '../../lib/StandardColors.js' import { txSignatureToString } from '../../lib/util.js' import { readCmConfig } from '../../lib/cm/cm-utils.js' +import umiSendAndConfirmTransaction from '../../lib/umi/sendAndConfirm.js' export default class CmWithdraw extends TransactionCommand { static override description = `Withdraw a candy machine and recover funds @@ -91,7 +92,7 @@ export default class CmWithdraw extends TransactionCommand { } const res = await this.withdraw(candyMachinePk); - const signature = txSignatureToString(res.signature) + const signature = txSignatureToString(res.transaction.signature as Uint8Array) this.log(`${terminalColors.BgGreen}${terminalColors.FgWhite}Candy machine withdrawn successfully${terminalColors.FgDefault}${terminalColors.BgDefault}`); this.log(`${terminalColors.BgGreen}${terminalColors.FgWhite}Candy machine ID: ${candyMachineId}${terminalColors.FgDefault}${terminalColors.BgDefault}`); this.log(`${terminalColors.BgGreen}${terminalColors.FgWhite}Transaction hash: ${signature}${terminalColors.FgDefault}${terminalColors.BgDefault}`); @@ -108,10 +109,10 @@ export default class CmWithdraw extends TransactionCommand { private async withdraw(candyMachinePk: ReturnType) { const { umi } = this.context; - const res = await deleteCandyMachine(umi, { + const tx = deleteCandyMachine(umi, { candyMachine: candyMachinePk, - }).sendAndConfirm(umi); + }); - return res; + return await umiSendAndConfirmTransaction(umi, tx); } } diff --git a/src/commands/config/wallets/add.ts b/src/commands/config/wallets/add.ts index 7151f61..0dd2ef2 100644 --- a/src/commands/config/wallets/add.ts +++ b/src/commands/config/wallets/add.ts @@ -1,84 +1,160 @@ -import { Args, Command } from '@oclif/core' +import { Args, Command, Flags } from '@oclif/core' import fs from 'fs' import { dirname } from 'path' -import { createSignerFromPath, getDefaultConfigPath, readConfig } from '../../../lib/Context.js' +import { fetchAsset, findAssetSignerPda } from '@metaplex-foundation/mpl-core' +import { publicKey } from '@metaplex-foundation/umi' +import { createUmi } from '@metaplex-foundation/umi-bundle-defaults' +import { mplCore } from '@metaplex-foundation/mpl-core' +import { createSignerFromPath, consolidateConfigs, DEFAULT_CONFIG, getDefaultConfigPath, readConfig, WalletEntry } from '../../../lib/Context.js' import { ensureDirectoryExists, writeJsonSync } from '../../../lib/file.js' -import { shortenAddress } from '../../../lib/util.js' +import { shortenAddress, DUMMY_UMI } from '../../../lib/util.js' export default class ConfigWalletAddCommand extends Command { static enableJsonFlag = true - static override description = 'Add a new wallet to your configuration' + static override description = 'Add a new wallet to your configuration. Use --asset to add an asset-signer wallet.' static override args = { name: Args.string({ description: 'Name of wallet (alphanumeric, hyphens and underscores only)', required: true, }), - path: Args.string({ description: 'Path to keypair json file', required: true }), + path: Args.string({ description: 'Path to keypair json file (not required for --asset)', required: false }), + } + + static override flags = { + asset: Flags.string({ + description: 'Asset ID to create an asset-signer wallet from', + }), } static override examples = [ '<%= config.bin %> <%= command.id %> my-wallet ~/.config/solana/id.json', '<%= config.bin %> <%= command.id %> mainnet-wallet ./wallets/mainnet.json', - '<%= config.bin %> <%= command.id %> dev-wallet /Users/dev/.solana/devnet.json', + '<%= config.bin %> <%= command.id %> vault --asset ', ] public async run(): Promise { const { flags, args } = await this.parse(ConfigWalletAddCommand) - // Validate name (removed character limit for MCP compatibility) - // Validate name contains only safe characters for all platforms - // TODO: Move validation to validations file that is in other PR if (!/^[a-zA-Z0-9-_]+$/.test(args.name)) { this.error(`Invalid wallet name '${args.name}'. Name must contain only letters, numbers, hyphens (-), and underscores (_). Example: 'my-wallet' or 'dev_wallet_1'`) } - // Validate path - if (!args.path.endsWith('.json')) { - this.error(`Invalid file type. Wallet file must be a .json keypair file. Received: ${args.path}`) + const configPath = flags.config ?? getDefaultConfigPath() + const config = readConfig(configPath) + + if (!config.wallets) { + config.wallets = [] } - // Check if the file exists - if (!fs.existsSync(args.path)) { - this.error(`Wallet file not found at: ${args.path}\nPlease check the path and ensure the keypair file exists.`) + // Check for duplicate name + const existingName = config.wallets.find((wallet) => wallet.name === args.name) + if (existingName) { + this.error(`A wallet named '${args.name}' already exists.\nUse a different name or run 'mplx config wallets remove ${args.name}' to remove the existing wallet first.`) } - const path = flags.config ?? getDefaultConfigPath() + let wallet: WalletEntry - const config = readConfig(path) + if (flags.asset) { + // Asset-signer wallet + const assetPubkey = publicKey(flags.asset) + const [pdaPubkey] = findAssetSignerPda(DUMMY_UMI, { asset: assetPubkey }) - const signer = await createSignerFromPath(args.path) + // Fetch the asset on-chain to determine the owner + const mergedConfig = consolidateConfigs(DEFAULT_CONFIG, config, { rpcUrl: flags.rpc }) + const umi = createUmi(mergedConfig.rpcUrl!).use(mplCore()) + const asset = await fetchAsset(umi, assetPubkey).catch(() => { + this.error(`Could not fetch asset ${flags.asset}. Make sure it exists and your RPC is reachable.`) + }) - if (!config.wallets) { - config.wallets = [] - } else { - const existingName = config.wallets.find((wallet) => wallet.name === args.name) - if (existingName) { - this.error(`A wallet named '${args.name}' already exists.\nUse a different name or run 'mplx config wallets remove ${args.name}' to remove the existing wallet first.`) - } + const ownerAddress = asset.owner.toString() - const existingPath = config.wallets.find((wallet) => wallet.path === args.path) - if (existingPath) { - this.error(`This wallet file is already configured as '${existingPath.name}'.\nUse 'mplx config wallets set ${existingPath.name}' to switch to it.`) + // Find the saved wallet that matches the asset owner + const ownerWallet = config.wallets.find( + w => w.address === ownerAddress && w.type !== 'asset-signer' + ) + + if (!ownerWallet) { + this.error( + `Asset owner ${shortenAddress(ownerAddress)} is not in your saved wallets.\n` + + `Add the owner wallet first: mplx config wallets add ` + ) } - const existingAddress = config.wallets.find((wallet) => wallet.address === signer.publicKey.toString()) + const existingAddress = config.wallets.find((w) => w.address === pdaPubkey.toString()) if (existingAddress) { - this.error(`This wallet address (${shortenAddress(signer.publicKey)}) is already configured as '${existingAddress.name}'.\nUse 'mplx config wallets set ${existingAddress.name}' to switch to it.`) + this.error(`This asset's signer PDA (${shortenAddress(pdaPubkey)}) is already configured as '${existingAddress.name}'.`) + } + + wallet = { + name: args.name, + type: 'asset-signer', + asset: flags.asset, + address: pdaPubkey.toString(), + payer: ownerWallet.name, + } + + config.wallets.push(wallet) + + const dir = dirname(configPath) + ensureDirectoryExists(dir) + writeJsonSync(configPath, config) + + this.log( + `✅ Asset-signer wallet '${args.name}' added!\n` + + ` Asset: ${flags.asset}\n` + + ` Signer PDA: ${pdaPubkey.toString()}\n` + + ` Owner: ${ownerWallet.name} (${shortenAddress(ownerAddress)})\n` + + `\nUse 'mplx config wallets set ${args.name}' to make this your active wallet.` + ) + + return { + name: args.name, + type: 'asset-signer', + asset: flags.asset, + address: pdaPubkey.toString(), + owner: ownerWallet.name, } } - config.wallets?.push({ + // Standard file-based wallet + if (!args.path) { + this.error('Path to keypair file is required for file-based wallets. Use --asset for asset-signer wallets.') + } + + if (!args.path.endsWith('.json')) { + this.error(`Invalid file type. Wallet file must be a .json keypair file. Received: ${args.path}`) + } + + if (!fs.existsSync(args.path)) { + this.error(`Wallet file not found at: ${args.path}\nPlease check the path and ensure the keypair file exists.`) + } + + const signer = await createSignerFromPath(args.path) + + const existingPath = config.wallets.find((w) => 'path' in w && w.path === args.path) + if (existingPath) { + this.error(`This wallet file is already configured as '${existingPath.name}'.\nUse 'mplx config wallets set ${existingPath.name}' to switch to it.`) + } + + const existingAddress = config.wallets.find((w) => w.address === signer.publicKey.toString()) + if (existingAddress) { + this.error(`This wallet address (${shortenAddress(signer.publicKey)}) is already configured as '${existingAddress.name}'.\nUse 'mplx config wallets set ${existingAddress.name}' to switch to it.`) + } + + wallet = { name: args.name, - address: signer.publicKey, + address: signer.publicKey.toString(), path: args.path, - }) + } + + config.wallets.push(wallet) - const dir = dirname(path) + const dir = dirname(configPath) ensureDirectoryExists(dir) - writeJsonSync(path, config) + writeJsonSync(configPath, config) this.log(`✅ Wallet '${args.name}' successfully added to configuration!\n Address: ${signer.publicKey}\n Path: ${args.path}\n\nUse 'mplx config wallets set ${args.name}' to make this your active wallet.`) diff --git a/src/commands/config/wallets/list.ts b/src/commands/config/wallets/list.ts index 6cc5885..e286b44 100644 --- a/src/commands/config/wallets/list.ts +++ b/src/commands/config/wallets/list.ts @@ -20,18 +20,24 @@ export default class ConfigWalletListCommand extends Command { return { wallets: [] } } - const wallets = config.wallets.map(wallet => ({ - name: wallet.name, - address: wallet.address, - path: wallet.path, - active: wallet.path === config.keypair, - })) + const wallets = config.wallets.map(wallet => { + const type = wallet.type || 'file' + const isActive = type === 'asset-signer' + ? wallet.name === config.activeWallet + : ('path' in wallet && wallet.path === config.keypair && !config.activeWallet) + + return { + name: wallet.name, + address: wallet.address, + type, + active: isActive, + ...(type === 'asset-signer' && 'asset' in wallet ? { asset: wallet.asset } : {}), + ...('path' in wallet ? { path: wallet.path } : {}), + } + }) this.log('Installed Wallets:') - for (const wallet of wallets) { - const marker = wallet.active ? ' (active)' : '' - this.log(` ${wallet.name}: ${wallet.address}${marker}`) - } + console.table(wallets) return { wallets } } diff --git a/src/commands/config/wallets/new.ts b/src/commands/config/wallets/new.ts index 4bd57d8..370601d 100644 --- a/src/commands/config/wallets/new.ts +++ b/src/commands/config/wallets/new.ts @@ -91,7 +91,7 @@ export default class ConfigWalletsNew extends BaseCommand w.path === filePath) + const existingPath = config.wallets.find((w) => 'path' in w && w.path === filePath) if (existingPath) { this.error(`Wallet with path ${filePath} already exists`) } diff --git a/src/commands/config/wallets/set.ts b/src/commands/config/wallets/set.ts index 79cc579..2bee021 100644 --- a/src/commands/config/wallets/set.ts +++ b/src/commands/config/wallets/set.ts @@ -12,9 +12,9 @@ export default class ConfigWalletSetCommand extends Command { static override description = 'Set a new active wallet from a list of wallets. If no name is provided, opens interactive wallet selector.' static override args = { - name: Args.string({ + name: Args.string({ description: 'Name of the wallet to set as active', - required: false + required: false }) } @@ -33,8 +33,9 @@ export default class ConfigWalletSetCommand extends Command { const availableWallets = config.wallets.map(wallet => ({ name: wallet.name, - path: wallet.path, - publicKey: wallet.address + path: 'path' in wallet ? wallet.path : undefined, + publicKey: wallet.address, + type: wallet.type || 'file', })) let selectedWallet @@ -42,27 +43,49 @@ export default class ConfigWalletSetCommand extends Command { if (args.name) { // Find wallet by name selectedWallet = availableWallets.find(wallet => wallet.name === args.name) - + if (!selectedWallet) { this.error(`Wallet with name "${args.name}" not found. Available wallets: ${availableWallets.map(w => w.name).join(', ')}`) } } else { - // Use interactive selector - selectedWallet = await walletSelectorPrompt(availableWallets) + // Use interactive selector — adapt for walletSelectorPrompt interface + const promptWallets = availableWallets.map(w => ({ + name: w.type === 'asset-signer' ? `${w.name} (asset-signer)` : w.name, + path: w.path || '', + publicKey: w.publicKey, + })) + const selected = await walletSelectorPrompt(promptWallets) + selectedWallet = availableWallets.find(w => w.publicKey === selected.publicKey) + } + + if (!selectedWallet) { + this.error('Failed to select wallet') } - config.keypair = selectedWallet.path + if (selectedWallet.type === 'asset-signer') { + // For asset-signer wallets, set activeWallet name instead of keypair + config.activeWallet = selectedWallet.name + } else { + // For file/ledger wallets, set keypair path and clear any active asset-signer + if (!selectedWallet.path) { + this.error('Wallet path is missing') + } + config.keypair = selectedWallet.path + delete config.activeWallet + } const dir = dirname(path) ensureDirectoryExists(dir) writeJsonSync(path, config) - this.log(`Selected wallet: ${selectedWallet.name} (${shortenAddress(selectedWallet.publicKey)})`) + const typeLabel = selectedWallet.type === 'asset-signer' ? ' (asset-signer)' : '' + this.log(`Selected wallet: ${selectedWallet.name}${typeLabel} (${shortenAddress(selectedWallet.publicKey)})`) return { name: selectedWallet.name, address: selectedWallet.publicKey, - path: selectedWallet.path, + type: selectedWallet.type, + ...(selectedWallet.path ? { path: selectedWallet.path } : {}), } } -} \ No newline at end of file +} diff --git a/src/commands/core/asset/execute/index.ts b/src/commands/core/asset/execute/index.ts new file mode 100644 index 0000000..15a8129 --- /dev/null +++ b/src/commands/core/asset/execute/index.ts @@ -0,0 +1,13 @@ +import { Command } from '@oclif/core' + +export default class CoreAssetExecute extends Command { + static override description = 'Execute instructions signed by an MPL Core Asset\'s signer PDA' + + static override examples = [ + '<%= config.bin %> core asset execute info ', + ] + + public async run(): Promise { + await this.parse(CoreAssetExecute) + } +} diff --git a/src/commands/core/asset/execute/info.ts b/src/commands/core/asset/execute/info.ts new file mode 100644 index 0000000..1b29132 --- /dev/null +++ b/src/commands/core/asset/execute/info.ts @@ -0,0 +1,55 @@ +import { fetchAsset, findAssetSignerPda } from '@metaplex-foundation/mpl-core' +import { amountToNumber, publicKey } from '@metaplex-foundation/umi' +import { Args } from '@oclif/core' +import ora from 'ora' + +import { TransactionCommand } from '../../../../TransactionCommand.js' + +export default class ExecuteInfo extends TransactionCommand { + static override description = 'Show the asset signer PDA address and its SOL balance' + + static override examples = [ + '<%= config.bin %> <%= command.id %> ', + ] + + static override args = { + assetId: Args.string({ description: 'Asset ID to derive the signer PDA for', required: true }), + } + + public async run(): Promise { + const { args } = await this.parse(ExecuteInfo) + const { umi } = this.context + + const spinner = ora('Fetching asset signer info...').start() + + try { + const assetPubkey = publicKey(args.assetId) + + // Verify asset exists + await fetchAsset(umi, assetPubkey) + + const [assetSignerPda] = findAssetSignerPda(umi, { asset: assetPubkey }) + const balance = await umi.rpc.getBalance(assetSignerPda) + const balanceNumber = amountToNumber(balance) + + spinner.succeed('Asset signer info retrieved') + + this.logSuccess( + `-------------------------------- + Asset: ${args.assetId} + Signer PDA: ${assetSignerPda.toString()} + SOL Balance: ${balanceNumber} SOL +--------------------------------` + ) + + return { + asset: args.assetId, + signerPda: assetSignerPda.toString(), + balance: balanceNumber, + } + } catch (error) { + spinner.fail('Failed to fetch asset signer info') + throw error + } + } +} diff --git a/src/commands/core/asset/transfer.ts b/src/commands/core/asset/transfer.ts index d161e02..376eed3 100644 --- a/src/commands/core/asset/transfer.ts +++ b/src/commands/core/asset/transfer.ts @@ -5,6 +5,7 @@ import ora from 'ora' import { generateExplorerUrl } from '../../../explorers.js' import { TransactionCommand } from '../../../TransactionCommand.js' +import umiSendAndConfirmTransaction from '../../../lib/umi/sendAndConfirm.js' import { txSignatureToString } from '../../../lib/util.js' export default class AssetTransfer extends TransactionCommand { @@ -55,13 +56,15 @@ export default class AssetTransfer extends TransactionCommand if (asset.updateAuthority.type === 'Collection' && asset.updateAuthority.address) { collection = await fetchCollection(umi, publicKey(asset.updateAuthority.address)) } - const tx = await update(umi, { asset, collection, name, uri }).sendAndConfirm(umi) - const signature = txSignatureToString(tx.signature) + const txBuilder = update(umi, { asset, collection, name, uri }) + const tx = await umiSendAndConfirmTransaction(umi, txBuilder) + const signature = txSignatureToString(tx.transaction.signature as Uint8Array) const explorerUrl = generateExplorerUrl(this.context.explorer, this.context.chain, signature, 'transaction') spinner.succeed(`Asset updated: ${asset.publicKey} (Tx: ${signature})`) return { diff --git a/src/commands/core/collection/create.ts b/src/commands/core/collection/create.ts index 976f53a..dc03f3f 100644 --- a/src/commands/core/collection/create.ts +++ b/src/commands/core/collection/create.ts @@ -12,6 +12,7 @@ import { ExplorerType, generateExplorerUrl } from '../../../explorers.js' import createAssetPrompt, { CreateAssetPromptResult } from '../../../prompts/createAssetPrompt.js' import uploadFile from '../../../lib/uploader/uploadFile.js' import uploadJson from '../../../lib/uploader/uploadJson.js' +import umiSendAndConfirmTransaction from '../../../lib/umi/sendAndConfirm.js' export default class CoreCollectionCreate extends TransactionCommand { static override description = `Create an MPL Core Collection using 3 different methods: @@ -144,19 +145,20 @@ export default class CoreCollectionCreate extends TransactionCommand { spinner.fail(`Error creating Collection: ${error}`) throw error }) - const txStr = txSignatureToString(tx.signature) + const txStr = txSignatureToString(tx.transaction.signature as Uint8Array) spinner.succeed('Collection created successfully') const display = await this.formatCollectionResult(collection.publicKey, txStr, explorer) this.log(display) @@ -239,19 +241,20 @@ export default class CoreCollectionCreate extends TransactionCommand { spinner.fail(`Error creating Collection: ${error}`) throw error }) - const txStr = txSignatureToString(tx.signature) + const txStr = txSignatureToString(tx.transaction.signature as Uint8Array) spinner.succeed('Collection created successfully') const display = await this.formatCollectionResult(collection.publicKey, txStr, explorer) this.log(display) @@ -275,19 +278,20 @@ export default class CoreCollectionCreate extends TransactionCommand { spinner.fail(`Error creating Collection: ${error}`) throw error }) - const txStr = txSignatureToString(tx.signature) + const txStr = txSignatureToString(tx.transaction.signature as Uint8Array) spinner.succeed('Collection created successfully') const display = await this.formatCollectionResult(collection.publicKey, txStr, explorer) this.log(display) diff --git a/src/commands/distro/create.ts b/src/commands/distro/create.ts index 39463e0..2958be7 100644 --- a/src/commands/distro/create.ts +++ b/src/commands/distro/create.ts @@ -251,7 +251,7 @@ You can either provide all required flags individually or use a distro config JS // Create the distribution const transaction = createDistribution(this.context.umi, { allowedDistributor, - authority: this.context.signer, + authority: this.context.umi.identity, distributionType, endTime, merkleRoot, diff --git a/src/commands/distro/deposit.ts b/src/commands/distro/deposit.ts index ebeabef..279a6e6 100644 --- a/src/commands/distro/deposit.ts +++ b/src/commands/distro/deposit.ts @@ -83,7 +83,7 @@ The distribution must be active and you must have the tokens in your wallet.` const depositorTokenAccount = findAssociatedTokenPda(this.context.umi, { mint, - owner: this.context.signer.publicKey, + owner: this.context.umi.identity.publicKey, }) const distributionTokenAccount = findAssociatedTokenPda(this.context.umi, { @@ -111,12 +111,12 @@ The distribution must be active and you must have the tokens in your wallet.` const transaction = deposit(this.context.umi, { amount: basisAmount, - depositor: this.context.signer, + depositor: this.context.umi.identity, depositorTokenAccount, distribution: distributionAddress, distributionTokenAccount, mint, - authority: this.context.signer, + authority: this.context.umi.identity, payer: this.context.payer, }) diff --git a/src/commands/distro/withdraw.ts b/src/commands/distro/withdraw.ts index f4e5d30..53fbb76 100644 --- a/src/commands/distro/withdraw.ts +++ b/src/commands/distro/withdraw.ts @@ -73,7 +73,7 @@ Withdrawals may be restricted during active distribution periods depending on th const distribution = await fetchDistribution(this.context.umi, distributionAddress) const {mint} = distribution - if (distribution.authority !== this.context.signer.publicKey) { + if (distribution.authority !== this.context.umi.identity.publicKey) { throw new Error(`Only the distribution authority can withdraw tokens. Authority: ${distribution.authority}`) } @@ -93,7 +93,7 @@ Withdrawals may be restricted during active distribution periods depending on th const recipient = flags.recipient ? publicKey(flags.recipient) - : this.context.signer.publicKey + : this.context.umi.identity.publicKey const recipientTokenAccount = findAssociatedTokenPda(this.context.umi, { mint, @@ -139,7 +139,7 @@ Withdrawals may be restricted during active distribution periods depending on th const withdrawIx = withdraw(this.context.umi, { amount: basisAmount, - authority: this.context.signer, + authority: this.context.umi.identity, distribution: distributionAddress, distributionTokenAccount, mint, diff --git a/src/commands/genesis/bucket/add-launch-pool.ts b/src/commands/genesis/bucket/add-launch-pool.ts index f8fb912..8bd47c8 100644 --- a/src/commands/genesis/bucket/add-launch-pool.ts +++ b/src/commands/genesis/bucket/add-launch-pool.ts @@ -235,7 +235,7 @@ Use Unix timestamps for absolute times.` genesisAccount: genesisAddress, baseMint: genesisAccount.baseMint, quoteMint: genesisAccount.quoteMint, - authority: this.context.signer, + authority: this.context.umi.identity, payer: this.context.payer, bucketIndex, baseTokenAllocation: allocation, @@ -255,7 +255,7 @@ Use Unix timestamps for absolute times.` try { spinner.text = 'Setting extensions...' const extensionsTx = addLaunchPoolBucketV2Extensions(this.context.umi, { - authority: this.context.signer, + authority: this.context.umi.identity, bucket: bucketPda, extensions, genesisAccount: genesisAddress, @@ -278,7 +278,7 @@ Use Unix timestamps for absolute times.` const setBehaviorsTx = setLaunchPoolBucketV2Behaviors(this.context.umi, { genesisAccount: genesisAddress, bucket: bucketPda, - authority: this.context.signer, + authority: this.context.umi.identity, payer: this.context.payer, padding: new Array(3).fill(0), endBehaviors, diff --git a/src/commands/genesis/bucket/add-presale.ts b/src/commands/genesis/bucket/add-presale.ts index f4ee139..62b224d 100644 --- a/src/commands/genesis/bucket/add-presale.ts +++ b/src/commands/genesis/bucket/add-presale.ts @@ -150,7 +150,7 @@ Use Unix timestamps for absolute times.` genesisAccount: genesisAddress, baseMint: genesisAccount.baseMint, quoteMint: genesisAccount.quoteMint, - authority: this.context.signer, + authority: this.context.umi.identity, payer: this.context.payer, bucketIndex, baseTokenAllocation: allocation, diff --git a/src/commands/genesis/bucket/add-unlocked.ts b/src/commands/genesis/bucket/add-unlocked.ts index 402534f..6456ac8 100644 --- a/src/commands/genesis/bucket/add-unlocked.ts +++ b/src/commands/genesis/bucket/add-unlocked.ts @@ -116,7 +116,7 @@ Instead, they allocate base tokens directly to a recipient.` genesisAccount: genesisAddress, baseMint: genesisAccount.baseMint, quoteMint: genesisAccount.quoteMint, - authority: this.context.signer, + authority: this.context.umi.identity, payer: this.context.payer, recipient: publicKey(flags.recipient), bucketIndex, diff --git a/src/commands/genesis/claim-unlocked.ts b/src/commands/genesis/claim-unlocked.ts index fc3ecab..d9dcd77 100644 --- a/src/commands/genesis/claim-unlocked.ts +++ b/src/commands/genesis/claim-unlocked.ts @@ -59,7 +59,7 @@ Requirements: const genesisAddress = publicKey(args.genesis) const recipientAddress = flags.recipient ? publicKey(flags.recipient) - : this.context.signer.publicKey + : this.context.umi.identity.publicKey // Fetch the Genesis account spinner.text = 'Fetching Genesis account details...' diff --git a/src/commands/genesis/claim.ts b/src/commands/genesis/claim.ts index 302e88a..296e2a7 100644 --- a/src/commands/genesis/claim.ts +++ b/src/commands/genesis/claim.ts @@ -61,7 +61,7 @@ Requirements: const genesisAddress = publicKey(args.genesis) const recipientAddress = flags.recipient ? publicKey(flags.recipient) - : this.context.signer.publicKey + : this.context.umi.identity.publicKey // Fetch the Genesis account spinner.text = 'Fetching Genesis account details...' diff --git a/src/commands/genesis/create.ts b/src/commands/genesis/create.ts index eedade3..de79e27 100644 --- a/src/commands/genesis/create.ts +++ b/src/commands/genesis/create.ts @@ -124,7 +124,7 @@ Funding Modes: const transaction = initializeV2(this.context.umi, { baseMint, quoteMint, - authority: this.context.signer, + authority: this.context.umi.identity, payer: this.context.payer, fundingMode, totalSupplyBaseToken: totalSupply, diff --git a/src/commands/genesis/deposit.ts b/src/commands/genesis/deposit.ts index 36471b3..5a0403a 100644 --- a/src/commands/genesis/deposit.ts +++ b/src/commands/genesis/deposit.ts @@ -103,8 +103,8 @@ Launch pools use a pro-rata allocation model where: bucket: bucketPda, baseMint: genesisAccount.baseMint, quoteMint: genesisAccount.quoteMint, - depositor: this.context.signer, - recipient: this.context.signer, + depositor: this.context.umi.identity, + recipient: this.context.umi.identity, rentPayer: this.context.payer, amountQuoteToken: amount, }) @@ -114,7 +114,7 @@ Launch pools use a pro-rata allocation model where: // Find the deposit PDA for reference const depositPda = findLaunchPoolDepositV2Pda(this.context.umi, { bucket: bucketPda, - recipient: this.context.signer.publicKey, + recipient: this.context.umi.identity.publicKey, }) spinner.succeed('Deposit successful!') diff --git a/src/commands/genesis/finalize.ts b/src/commands/genesis/finalize.ts index 75c7643..f9beae2 100644 --- a/src/commands/genesis/finalize.ts +++ b/src/commands/genesis/finalize.ts @@ -98,7 +98,7 @@ Requirements: const transaction = finalizeV2(this.context.umi, { genesisAccount: genesisAddress, baseMint: genesisAccount.baseMint, - authority: this.context.signer, + authority: this.context.umi.identity, }).addRemainingAccounts(bucketAccounts) const result = await umiSendAndConfirmTransaction(this.context.umi, transaction) diff --git a/src/commands/genesis/launch/create.ts b/src/commands/genesis/launch/create.ts index 1146275..3588e82 100644 --- a/src/commands/genesis/launch/create.ts +++ b/src/commands/genesis/launch/create.ts @@ -167,7 +167,7 @@ Launch types: if (flags.telegram) externalLinks.telegram = flags.telegram // Build token metadata - const wallet = this.context.signer.publicKey.toString() + const wallet = this.context.umi.identity.publicKey.toString() const token = { name: flags.name, symbol: flags.symbol, diff --git a/src/commands/genesis/launch/register.ts b/src/commands/genesis/launch/register.ts index 8b1e987..cc4fc95 100644 --- a/src/commands/genesis/launch/register.ts +++ b/src/commands/genesis/launch/register.ts @@ -115,7 +115,7 @@ provided as a JSON file via --launchConfig.` // Use the configured signer as wallet if not set in the config if (!launchConfig.wallet) { - launchConfig.wallet = this.context.signer.publicKey.toString() + launchConfig.wallet = this.context.umi.identity.publicKey.toString() } const apiConfig: GenesisApiConfig = { diff --git a/src/commands/genesis/presale/claim.ts b/src/commands/genesis/presale/claim.ts index 905f1f8..e08f536 100644 --- a/src/commands/genesis/presale/claim.ts +++ b/src/commands/genesis/presale/claim.ts @@ -62,7 +62,7 @@ Requirements: const genesisAddress = publicKey(args.genesis) const recipientAddress = flags.recipient ? publicKey(flags.recipient) - : this.context.signer.publicKey + : this.context.umi.identity.publicKey // Fetch the Genesis account spinner.text = 'Fetching Genesis account details...' diff --git a/src/commands/genesis/presale/deposit.ts b/src/commands/genesis/presale/deposit.ts index be8c6a8..0aaf805 100644 --- a/src/commands/genesis/presale/deposit.ts +++ b/src/commands/genesis/presale/deposit.ts @@ -100,8 +100,8 @@ Requirements: bucket: bucketPda, baseMint: genesisAccount.baseMint, quoteMint: genesisAccount.quoteMint, - depositor: this.context.signer, - recipient: this.context.signer, + depositor: this.context.umi.identity, + recipient: this.context.umi.identity, rentPayer: this.context.payer, amountQuoteToken: amount, }) diff --git a/src/commands/genesis/revoke.ts b/src/commands/genesis/revoke.ts index 8e4f9dd..aefb161 100644 --- a/src/commands/genesis/revoke.ts +++ b/src/commands/genesis/revoke.ts @@ -75,7 +75,7 @@ Options: const transaction = revokeV2(this.context.umi, { genesisAccount: genesisAddress, baseMint: genesisAccount.baseMint, - authority: this.context.signer, + authority: this.context.umi.identity, baseTokenProgram: publicKey('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), revokeMintAuthority: flags.revokeMint, revokeFreezeAuthority: flags.revokeFreeze, diff --git a/src/commands/genesis/withdraw.ts b/src/commands/genesis/withdraw.ts index e207723..ce61ddb 100644 --- a/src/commands/genesis/withdraw.ts +++ b/src/commands/genesis/withdraw.ts @@ -86,7 +86,7 @@ Requirements: // Verify the deposit exists const [depositPda] = findLaunchPoolDepositV2Pda(this.context.umi, { bucket: bucketPda, - recipient: this.context.signer.publicKey, + recipient: this.context.umi.identity.publicKey, }) spinner.text = 'Verifying deposit...' @@ -94,7 +94,7 @@ Requirements: if (!deposit) { spinner.fail('Deposit not found') - this.error(`No deposit found for signer ${this.context.signer.publicKey}. Make sure you have deposited into this launch pool.`) + this.error(`No deposit found for signer ${this.context.umi.identity.publicKey}. Make sure you have deposited into this launch pool.`) } // Parse and validate amount @@ -116,7 +116,7 @@ Requirements: bucket: bucketPda, baseMint: genesisAccount.baseMint, quoteMint: genesisAccount.quoteMint, - withdrawer: this.context.signer, + withdrawer: this.context.umi.identity, payer: this.context.payer, amountQuoteToken: amount, }) diff --git a/src/commands/tm/create.ts b/src/commands/tm/create.ts index 81ee3af..565d70f 100644 --- a/src/commands/tm/create.ts +++ b/src/commands/tm/create.ts @@ -9,6 +9,7 @@ import { TransactionCommand } from '../../TransactionCommand.js' import { ExplorerType, generateExplorerUrl } from '../../explorers.js' import uploadFile from '../../lib/uploader/uploadFile.js' import uploadJson from '../../lib/uploader/uploadJson.js' +import umiSendAndConfirmTransaction from '../../lib/umi/sendAndConfirm.js' import createTokenMetadataPrompt, { CreateTokenMetadataPromptResult, NftType } from '../../prompts/createTokenMetadataPrompt.js' import { txSignatureToString } from '../../lib/util.js' @@ -334,10 +335,10 @@ export default class TmCreate extends TransactionCommand { collection: input.collection ? some({ key: publicKey(input.collection), verified: false }) : undefined, }) - const result = await createNftIx.sendAndConfirm(umi) + const result = await umiSendAndConfirmTransaction(umi, createNftIx) return { mint: mint.publicKey.toString(), - signature: result.signature, + signature: result.transaction.signature as Uint8Array, } } diff --git a/src/commands/tm/transfer.ts b/src/commands/tm/transfer.ts index 783d002..ec63579 100644 --- a/src/commands/tm/transfer.ts +++ b/src/commands/tm/transfer.ts @@ -15,6 +15,7 @@ import { TransactionCommand } from '../../TransactionCommand.js' import { txSignatureToString } from '../../lib/util.js' import { generateExplorerUrl } from '../../explorers.js' import { TOKEN_AUTH_RULES_ID } from '../../constants.js' +import umiSendAndConfirmTransaction from '../../lib/umi/sendAndConfirm.js' export default class TmTransfer extends TransactionCommand { static override description = 'Transfer an MPL Token Metadata NFT to a new owner. Automatically detects pNFTs and includes ruleset if present.' @@ -110,14 +111,14 @@ export default class TmTransfer extends TransactionCommand { }) } - const result = await transferIx.sendAndConfirm(umi).catch((err) => { + const result = await umiSendAndConfirmTransaction(umi, transferIx).catch((err) => { transferSpinner.fail('Failed to transfer NFT') throw err }) transferSpinner.succeed('NFT transferred successfully!') - const signature = txSignatureToString(result.signature as Uint8Array) + const signature = txSignatureToString(result.transaction.signature as Uint8Array) this.logSuccess( `-------------------------------- NFT: ${asset.metadata.name} diff --git a/src/commands/tm/update.ts b/src/commands/tm/update.ts index ca538d2..20ec0ac 100644 --- a/src/commands/tm/update.ts +++ b/src/commands/tm/update.ts @@ -23,6 +23,7 @@ import { generateExplorerUrl } from '../../explorers.js' import imageUploader from '../../lib/uploader/imageUploader.js' import uploadJson from '../../lib/uploader/uploadJson.js' import { TOKEN_AUTH_RULES_ID } from '../../constants.js' +import umiSendAndConfirmTransaction from '../../lib/umi/sendAndConfirm.js' export default class TmUpdate extends TransactionCommand { static override description = 'Update an MPL Token Metadata NFT. Automatically detects pNFTs and includes ruleset if present. Use --editor to edit the metadata JSON in your default editor.' @@ -187,7 +188,7 @@ export default class TmUpdate extends TransactionCommand { }) } - const result = await updateIx.sendAndConfirm(umi).catch((err) => { + const result = await umiSendAndConfirmTransaction(umi, updateIx).catch((err) => { updateSpinner.fail('Failed to update NFT') throw err }) @@ -196,7 +197,7 @@ export default class TmUpdate extends TransactionCommand { return { asset, - signature: result.signature, + signature: result.transaction.signature, } } @@ -325,7 +326,7 @@ export default class TmUpdate extends TransactionCommand { }) } - const result = await updateIx.sendAndConfirm(umi).catch((err) => { + const result = await umiSendAndConfirmTransaction(umi, updateIx).catch((err) => { updateSpinner.fail('Failed to update NFT') throw err }) @@ -334,7 +335,7 @@ export default class TmUpdate extends TransactionCommand { return { asset, - signature: result.signature, + signature: result.transaction.signature, } } diff --git a/src/commands/toolbox/raw.ts b/src/commands/toolbox/raw.ts new file mode 100644 index 0000000..06270d0 --- /dev/null +++ b/src/commands/toolbox/raw.ts @@ -0,0 +1,121 @@ +import { transactionBuilder } from '@metaplex-foundation/umi' +import { Flags } from '@oclif/core' +import ora from 'ora' + +import { generateExplorerUrl } from '../../explorers.js' +import { TransactionCommand } from '../../TransactionCommand.js' +import { deserializeInstruction } from '../../lib/execute/deserializeInstruction.js' +import umiSendAndConfirmTransaction from '../../lib/umi/sendAndConfirm.js' +import { txSignatureToString } from '../../lib/util.js' + +export default class ToolboxRaw extends TransactionCommand { + static override description = `Execute arbitrary base64-encoded Solana instructions. + +Instructions are signed by the current wallet. When an asset-signer wallet is +active, they are automatically wrapped in an MPL Core execute instruction. + +Use --instruction for each instruction to include (can be repeated). +Alternatively, pipe instructions via stdin with --stdin.` + + static override examples = [ + '<%= config.bin %> <%= command.id %> --instruction ', + '<%= config.bin %> <%= command.id %> --instruction --instruction ', + 'echo "" | <%= config.bin %> <%= command.id %> --stdin', + ] + + static override flags = { + instruction: Flags.string({ + char: 'i', + description: 'Base64-encoded instruction (can be repeated)', + multiple: true, + }), + stdin: Flags.boolean({ + description: 'Read base64-encoded instructions from stdin (one per line)', + exclusive: ['instruction'], + }), + } + + private async readStdin(): Promise { + return new Promise((resolve, reject) => { + let data = '' + process.stdin.setEncoding('utf8') + process.stdin.on('data', (chunk) => { data += chunk }) + process.stdin.on('end', () => { + const lines = data.split('\n').map(l => l.trim()).filter(l => l.length > 0) + resolve(lines) + }) + process.stdin.on('error', reject) + }) + } + + public async run(): Promise { + const { flags } = await this.parse(ToolboxRaw) + const { umi, explorer, chain } = this.context + + let instructionData: string[] + + if (flags.stdin) { + instructionData = await this.readStdin() + } else if (flags.instruction && flags.instruction.length > 0) { + instructionData = flags.instruction + } else { + this.error('You must provide instructions via --instruction or --stdin') + } + + if (instructionData.length === 0) { + this.error('No instructions provided') + } + + const spinner = ora('Deserializing instructions...').start() + + try { + const instructions = instructionData.map((b64, idx) => { + try { + return deserializeInstruction(b64) + } catch (error) { + spinner.fail(`Failed to deserialize instruction ${idx + 1}`) + this.error(`Failed to deserialize instruction ${idx + 1}: ${error}`) + } + }) + + spinner.text = `Executing ${instructions.length} instruction(s)...` + + // bytesCreatedOnChain is 0 since we can't infer account creation from raw instructions + const tx = instructions.reduce( + (builder, ix) => builder.add({ instruction: ix, signers: [umi.identity], bytesCreatedOnChain: 0 }), + transactionBuilder(), + ) + + const result = await umiSendAndConfirmTransaction(umi, tx) + + const { signature } = result.transaction + if (signature === null) { + throw new Error('Transaction signature is null') + } + + const sig = typeof signature === 'string' ? signature : txSignatureToString(signature as Uint8Array) + const explorerUrl = generateExplorerUrl(explorer, chain, sig, 'transaction') + + spinner.succeed(`Executed ${instructions.length} instruction(s)`) + + this.logSuccess( + `-------------------------------- + Signer: ${umi.identity.publicKey.toString()} + Instructions: ${instructions.length} + Signature: ${sig} +--------------------------------` + ) + this.log(explorerUrl) + + return { + signer: umi.identity.publicKey.toString(), + instructionCount: instructions.length, + signature: sig, + explorer: explorerUrl, + } + } catch (error) { + spinner.fail('Failed to execute instructions') + throw error + } + } +} diff --git a/src/commands/toolbox/sol/airdrop.ts b/src/commands/toolbox/sol/airdrop.ts index fe4bf57..dcb6e78 100644 --- a/src/commands/toolbox/sol/airdrop.ts +++ b/src/commands/toolbox/sol/airdrop.ts @@ -49,7 +49,7 @@ export default class ToolboxSolAirdrop extends TransactionCommand = [ 'wallets', 'rpcs', 'explorer', + 'activeWallet', ] export const getDefaultConfigPath = (): string => { @@ -142,25 +154,94 @@ export function consolidateConfigs(...configs: Partial[]): Config }, {} as ConfigJson) } +/** + * Resolves the active wallet from config. Returns the wallet entry if an + * asset-signer wallet is active, or undefined for standard wallets. + */ +const resolveActiveWallet = (config: ConfigJson): WalletEntry | undefined => { + if (!config.activeWallet || !config.wallets) return undefined + return config.wallets.find(w => w.name === config.activeWallet) +} + export const createContext = async (configPath: string, overrides: ConfigJson, isTransactionContext: boolean = false): Promise => { const config: ConfigJson = consolidateConfigs(DEFAULT_CONFIG, readConfig(configPath), overrides) + // Check if the active wallet is an asset-signer. + // An explicit --keypair flag overrides the asset-signer wallet. + const activeWallet = overrides.keypair ? undefined : resolveActiveWallet(config) + const isAssetSigner = activeWallet?.type === 'asset-signer' + let signer: Signer - if (isTransactionContext) { - signer = await createSignerFromPath(config.keypair) + let assetSigner: AssetSignerInfo | undefined + let payerPath: string | undefined = config.payer + let pdaIdentity: Signer | undefined + let realWalletSigner: Signer | undefined + + if (isAssetSigner) { + // Asset-signer mode: umi.identity AND umi.payer = noopSigner(PDA) so + // instructions are built with the PDA for all accounts naturally. + // The send layer wraps in execute() with the asset owner as authority + // and the fee payer (which can differ via -p). + + // Resolve the asset owner wallet (authority on the execute instruction). + // This is always the wallet configured on the asset-signer entry. + let ownerPath: string | undefined + const walletPayerName = activeWallet.payer + if (walletPayerName && config.wallets) { + const ownerWallet = config.wallets.find(w => w.name === walletPayerName) + if (ownerWallet && ownerWallet.type !== 'asset-signer' && 'path' in ownerWallet) { + ownerPath = ownerWallet.path + } + } + + if (!ownerPath) { + ownerPath = config.keypair + } + + if (!ownerPath && isTransactionContext) { + throw new Error( + `Asset-signer wallet '${activeWallet.name}' could not resolve an owner wallet.\n` + + `Ensure the asset owner is a saved wallet, or set a default keypair in your config.` + ) + } + + if (ownerPath) { + realWalletSigner = await createSignerFromPath(ownerPath) + signer = realWalletSigner + } else { + signer = createNoopSigner() + } + + // Identity is a noop signer keyed to the PDA — instructions naturally + // use the PDA address. The send layer wraps them in execute(). + pdaIdentity = createUmiNoopSigner(publicKey(activeWallet.address)) + assetSigner = { asset: activeWallet.asset } + } else if (isTransactionContext) { + signer = await createSignerFromPath(overrides.keypair || config.keypair) } else { signer = config.keypair ? await createSignerFromPath(config.keypair) : createNoopSigner() } - const payer = config.payer ? await createSignerFromPath(config.payer) : signer + // For asset-signer mode, payer is already set as assetSignerPayer above. + // For normal mode, resolve payer from payerPath or fall back to signer. + const payer = isAssetSigner ? signer : (payerPath ? await createSignerFromPath(payerPath) : signer) const umi = createUmi(config.rpcUrl!, { commitment: config.commitment!, }) - umi.use(signerIdentity(signer)) - .use(signerPayer(payer)) - .use(mplCore()) + if (isAssetSigner) { + // Both identity and payer = noopSigner(PDA). Instructions are built with + // the PDA for all accounts. The send layer overrides the transaction fee + // payer to the real wallet via setFeePayer() before buildAndSign. + umi.use(signerIdentity(pdaIdentity!)) + .use(signerPayer(pdaIdentity!)) + } else { + umi.use(signerIdentity(signer)) + .use(signerPayer(payer)) + } + + umi.use(mplCore()) .use(mplTokenMetadata()) .use(mplToolbox()) .use(mplBubblegum()) @@ -169,6 +250,14 @@ export const createContext = async (configPath: string, overrides: ConfigJson, i .use(genesis()) .use(dasApi()) + if (assetSigner && realWalletSigner) { + // Resolve fee payer: -p flag overrides, otherwise the owner pays. + const feePayer = payerPath + ? await createSignerFromPath(payerPath) + : realWalletSigner + umi.use(assetSignerPlugin({ info: assetSigner, authority: realWalletSigner, payer: feePayer })) + } + const storageProvider = await initStorageProvider(config) storageProvider && umi.use(storageProvider) diff --git a/src/lib/execute/deserializeInstruction.ts b/src/lib/execute/deserializeInstruction.ts new file mode 100644 index 0000000..8f472a7 --- /dev/null +++ b/src/lib/execute/deserializeInstruction.ts @@ -0,0 +1,96 @@ +import { Instruction, PublicKey } from '@metaplex-foundation/umi' +import { publicKey as publicKeySerializer } from '@metaplex-foundation/umi/serializers' + +const pkSerializer = publicKeySerializer() + +/** + * Deserializes a base64-encoded Solana instruction. + * + * Format (compact binary): + * - 32 bytes: program ID + * - 2 bytes (u16 LE): number of accounts + * - For each account: + * - 32 bytes: pubkey + * - 1 byte: flags (bit 0 = isSigner, bit 1 = isWritable) + * - Remaining bytes: instruction data + */ +export function deserializeInstruction(base64: string): Instruction { + const buffer = Buffer.from(base64, 'base64') + let offset = 0 + + if (buffer.length < 34) { + throw new Error('Instruction data too short') + } + + // Program ID (32 bytes) + const [programId, nextOffset] = pkSerializer.deserialize(buffer, offset) + offset = nextOffset + + // Number of accounts (u16 LE) + const numAccounts = buffer.readUInt16LE(offset) + offset += 2 + + const keys: Instruction['keys'] = [] + for (let i = 0; i < numAccounts; i++) { + if (offset + 33 > buffer.length) { + throw new Error(`Unexpected end of data reading account ${i + 1}`) + } + + const [pubkey, pubkeyEnd] = pkSerializer.deserialize(buffer, offset) + offset = pubkeyEnd + + const flags = buffer[offset] + offset += 1 + + keys.push({ + pubkey: pubkey as PublicKey, + isSigner: (flags & 0x01) !== 0, + isWritable: (flags & 0x02) !== 0, + }) + } + + // Remaining bytes are instruction data + const data = new Uint8Array(buffer.subarray(offset)) + + return { + programId: programId as PublicKey, + keys, + data, + } +} + +/** + * Serializes an instruction to base64, matching the format expected by deserializeInstruction. + */ +export function serializeInstruction(ix: Instruction): string { + const accountsSize = ix.keys.length * 33 + const buffer = Buffer.alloc(32 + 2 + accountsSize + ix.data.length) + let offset = 0 + + // Program ID + const programIdBytes = pkSerializer.serialize(ix.programId) + Buffer.from(programIdBytes).copy(buffer, offset) + offset += 32 + + // Number of accounts + buffer.writeUInt16LE(ix.keys.length, offset) + offset += 2 + + // Accounts + for (const key of ix.keys) { + const pubkeyBytes = pkSerializer.serialize(key.pubkey) + Buffer.from(pubkeyBytes).copy(buffer, offset) + offset += 32 + + let flags = 0 + if (key.isSigner) flags |= 0x01 + if (key.isWritable) flags |= 0x02 + buffer[offset] = flags + offset += 1 + } + + // Instruction data + Buffer.from(ix.data).copy(buffer, offset) + + return buffer.toString('base64') +} diff --git a/src/lib/umi/assetSignerPlugin.ts b/src/lib/umi/assetSignerPlugin.ts new file mode 100644 index 0000000..636f1f1 --- /dev/null +++ b/src/lib/umi/assetSignerPlugin.ts @@ -0,0 +1,32 @@ +import { Signer, Umi } from '@metaplex-foundation/umi' +import { AssetSignerInfo } from '../Context.js' + +export type AssetSignerState = { + info: AssetSignerInfo + authority: Signer // Asset owner — signs the execute instruction + payer: Signer // Fee payer — can differ from authority via -p flag +} + +const assetSignerStore = new WeakMap() + +/** + * Umi plugin that activates asset-signer mode. Stores the asset-signer state + * keyed by the umi instance so the send layer can wrap transactions in execute(). + * + * Both umi.identity and umi.payer are noopSigner(PDA), so instructions are + * built with the PDA for all accounts. The send layer uses authority as the + * execute caller and payer as the transaction fee payer via setFeePayer(). + */ +export const assetSignerPlugin = (state: AssetSignerState) => ({ + install(umi: Umi) { + assetSignerStore.set(umi, state) + }, +}) + +/** + * Reads asset-signer state for a umi instance. + * Returns undefined when no asset-signer wallet is active. + */ +export const getAssetSigner = (umi: Umi): AssetSignerState | undefined => { + return assetSignerStore.get(umi) +} diff --git a/src/lib/umi/sendAndConfirm.ts b/src/lib/umi/sendAndConfirm.ts index 977645b..ae081a1 100644 --- a/src/lib/umi/sendAndConfirm.ts +++ b/src/lib/umi/sendAndConfirm.ts @@ -1,8 +1,10 @@ import { TransactionBuilder, Umi } from '@metaplex-foundation/umi' +import { getAssetSigner } from './assetSignerPlugin.js' import umiConfirmTransaction from './confirmTransaction.js' import { UmiSendAndConfirmResponse } from './sendAllTransactionsAndConfirm.js' import { UmiSendOptions } from './sendOptions.js' import umiSendTransaction from './sendTransaction.js' +import { wrapForAssetSigner } from './wrapForAssetSigner.js' const umiSendAndConfirmTransaction = async ( umi: Umi, @@ -11,8 +13,16 @@ const umiSendAndConfirmTransaction = async ( ): Promise => { // TODO - Add Error handling + // If an asset-signer wallet is active (stored on umi via plugin), + // wrap the instructions in execute() automatically. + let tx = transaction + const assetSigner = getAssetSigner(umi) + if (assetSigner) { + tx = await wrapForAssetSigner(umi, transaction, assetSigner.info, assetSigner.authority, assetSigner.payer) + } + // Send transaction - const signature = await umiSendTransaction(umi, transaction, sendOptions) + const signature = await umiSendTransaction(umi, tx, sendOptions) // console.log('Signature: ', signature) if (signature.err) { diff --git a/src/lib/umi/sendTransaction.ts b/src/lib/umi/sendTransaction.ts index 3384f53..6ebb047 100644 --- a/src/lib/umi/sendTransaction.ts +++ b/src/lib/umi/sendTransaction.ts @@ -1,5 +1,6 @@ import { setComputeUnitPrice } from '@metaplex-foundation/mpl-toolbox' import { BlockhashWithExpiryBlockHeight, Signer, TransactionBuilder, TransactionSignature, Umi } from '@metaplex-foundation/umi' +import { getAssetSigner } from './assetSignerPlugin.js' import { UmiSendOptions } from './sendOptions.js' export interface UmiTransactionResponse { @@ -37,6 +38,14 @@ const umiSendTransaction = async ( ) } + // In asset-signer mode umi.payer is a noopSigner(PDA) so instructions use + // the PDA for payer accounts. Override the transaction fee payer to the real + // wallet so it pays fees and provides a valid signature. + const assetSigner = getAssetSigner(umi) + if (assetSigner) { + transaction = transaction.setFeePayer(assetSigner.payer) + } + let signedTx = await transaction.buildAndSign(umi).catch((error) => { throw new Error(`Error building and signing transaction: ${error.message}`) }) diff --git a/src/lib/umi/wrapForAssetSigner.ts b/src/lib/umi/wrapForAssetSigner.ts new file mode 100644 index 0000000..a6d83a7 --- /dev/null +++ b/src/lib/umi/wrapForAssetSigner.ts @@ -0,0 +1,67 @@ +import { execute, fetchAsset, fetchCollection } from '@metaplex-foundation/mpl-core' +import { publicKey, Signer, TransactionBuilder, Umi } from '@metaplex-foundation/umi' +import { AssetSignerInfo } from '../Context.js' + +/** + * Wraps a TransactionBuilder's instructions inside an MPL Core `execute` call + * so the asset's signer PDA signs for them on-chain. + * + * Since umi.identity is a noopSigner keyed to the PDA, instructions are + * already built with the PDA as authority — no rewriting needed. + * + * Inner signers (e.g., newly generated asset keypairs) are preserved and + * added to the execute transaction so they can sign the outer transaction. + * + * @param authority - The asset owner (signs the execute instruction) + * @param payer - The fee payer (can differ from authority via -p flag) + */ +export const wrapForAssetSigner = async ( + umi: Umi, + transaction: TransactionBuilder, + assetSigner: AssetSignerInfo, + authority: Signer, + payer: Signer, +): Promise => { + const assetPubkey = publicKey(assetSigner.asset) + const asset = await fetchAsset(umi, assetPubkey) + + let collection + if (asset.updateAuthority.type === 'Collection' && asset.updateAuthority.address) { + collection = await fetchCollection(umi, asset.updateAuthority.address) + } + + const instructions = transaction.getInstructions() + + // Collect inner signers (e.g., generated asset keypairs) that need to sign + // the outer transaction. Exclude the PDA noop, authority, and payer since + // execute() and setFeePayer() handle those. + const excludeKeys = new Set([ + umi.identity.publicKey.toString(), + payer.publicKey.toString(), + authority.publicKey.toString(), + ]) + const innerSigners = transaction.getSigners(umi).filter( + s => !excludeKeys.has(s.publicKey.toString()) + ) + + const execTx = execute(umi, { + asset, + collection, + instructions, + authority, + payer, + }) + + if (innerSigners.length === 0) { + return execTx + } + + // Inner signers (e.g., a generated asset keypair) must sign the outer + // transaction. getSigners() flattens all item signers, so which item + // they're attached to doesn't matter — append to the first. + const [first, ...rest] = execTx.items + return execTx.setItems([ + { ...first, signers: [...first.signers, ...innerSigners] }, + ...rest, + ]) +} diff --git a/test/commands/core/core.asset-signer.test.ts b/test/commands/core/core.asset-signer.test.ts new file mode 100644 index 0000000..a97f2b0 --- /dev/null +++ b/test/commands/core/core.asset-signer.test.ts @@ -0,0 +1,198 @@ +import { expect } from 'chai' +import { findAssetSignerPda, mplCore } from '@metaplex-foundation/mpl-core' +import { transferSol, mplToolbox } from '@metaplex-foundation/mpl-toolbox' +import { generateSigner, keypairIdentity, publicKey } from '@metaplex-foundation/umi' +import { createUmi } from '@metaplex-foundation/umi-bundle-defaults' +import fs from 'node:fs' +import path from 'node:path' +import os from 'node:os' +import { spawn } from 'child_process' +import { runCli, CLI_PATH, TEST_RPC, KEYPAIR_PATH } from '../../runCli' +import { createCoreAsset, extractAssetId, stripAnsi } from './corehelpers' + +/** + * Runs the CLI with a custom config (for asset-signer wallet tests). + * Uses -c instead of -k so the asset-signer config is picked up. + */ +const runCliWithConfig = ( + args: string[], + configPath: string, + stdin?: string[], +): Promise<{ stdout: string; stderr: string; code: number }> => { + return new Promise((resolve, reject) => { + const child = spawn('node', [CLI_PATH, ...args, '-r', TEST_RPC, '-c', configPath], { + stdio: ['pipe', 'pipe', 'pipe'], + }) + + let stdout = '' + let stderr = '' + + child.stdout.on('data', (data) => { stdout += data.toString() }) + child.stderr.on('data', (data) => { stderr += data.toString() }) + child.on('error', reject) + child.on('close', (code) => { + if (code !== 0) { + reject(new Error(`Process failed with code ${code}\nstderr: ${stderr}`)) + } else { + resolve({ stdout, stderr, code: 0 }) + } + }) + + if (stdin) { + for (const input of stdin) child.stdin.write(input) + child.stdin.end() + } + }) +} + +/** + * Writes a temporary config file with the asset-signer wallet active. + */ +const writeAssetSignerConfig = (assetId: string, pdaAddress: string, ownerAddress: string): string => { + const config = { + rpcUrl: TEST_RPC, + keypair: KEYPAIR_PATH, + activeWallet: 'vault', + wallets: [ + { + name: 'owner', + address: ownerAddress, + path: KEYPAIR_PATH, + }, + { + name: 'vault', + type: 'asset-signer', + asset: assetId, + address: pdaAddress, + payer: 'owner', + }, + ], + } + + const configPath = path.join(os.tmpdir(), `mplx-asset-signer-test-${Date.now()}.json`) + fs.writeFileSync(configPath, JSON.stringify(config, null, 2)) + return configPath +} + +describe('asset-signer wallet', function () { + this.timeout(120000) + + let signingAssetId: string + let signerPda: string + let ownerAddress: string + let configPath: string + const tempFiles: string[] = [] + + before(async () => { + const umi = createUmi(TEST_RPC).use(mplCore()).use(mplToolbox()) + const keypairData = JSON.parse(fs.readFileSync(KEYPAIR_PATH, 'utf-8')) + const kp = umi.eddsa.createKeypairFromSecretKey(new Uint8Array(keypairData)) + umi.use(keypairIdentity(kp)) + ownerAddress = kp.publicKey.toString() + + // Create the signing asset + const { assetId } = await createCoreAsset() + signingAssetId = assetId + + // Derive the PDA and fund it + const [pda] = findAssetSignerPda(umi, { asset: publicKey(signingAssetId) }) + signerPda = pda.toString() + + await transferSol(umi, { + destination: pda, + amount: { basisPoints: 500_000_000n, identifier: 'SOL', decimals: 9 }, + }).sendAndConfirm(umi) + // Wait for RPC state propagation on localnet + await new Promise(resolve => setTimeout(resolve, 2000)) + + // Write the asset-signer config + configPath = writeAssetSignerConfig(signingAssetId, signerPda, ownerAddress) + tempFiles.push(configPath) + }) + + after(() => { + for (const f of tempFiles) { + if (fs.existsSync(f)) fs.unlinkSync(f) + } + }) + + describe('SOL operations', () => { + it('shows the PDA balance when checking balance', async function () { + const { stdout, stderr, code } = await runCliWithConfig( + ['toolbox', 'sol', 'balance'], + configPath, + ) + + const output = stripAnsi(stdout) + stripAnsi(stderr) + expect(code).to.equal(0) + // Should show the shortened PDA address, not the wallet address + expect(output).to.contain(signerPda.slice(0, 4)) + expect(output).not.to.contain(ownerAddress.slice(0, 4)) + }) + + it('transfers SOL from the PDA', async function () { + const { stdout, stderr, code } = await runCliWithConfig( + ['toolbox', 'sol', 'transfer', '0.01', ownerAddress], + configPath, + ) + + const output = stripAnsi(stdout) + stripAnsi(stderr) + expect(code).to.equal(0) + expect(output).to.contain('SOL transferred successfully') + }) + }) + + describe('Core asset transfer', () => { + it('transfers a PDA-owned asset to a new owner', async function () { + // Create asset owned by the PDA (using standard CLI, not asset-signer) + const { stdout: out, stderr: err, code: c } = await runCli( + ['core', 'asset', 'create', '--name', 'PDA Owned', '--uri', 'https://example.com/pda', '--owner', signerPda], + ['\n'], + ) + expect(c).to.equal(0) + const targetAssetId = extractAssetId(stripAnsi(out)) || extractAssetId(stripAnsi(err)) + expect(targetAssetId).to.be.ok + + // Transfer it via asset-signer wallet + const { stdout, stderr, code } = await runCliWithConfig( + ['core', 'asset', 'transfer', targetAssetId!, ownerAddress], + configPath, + ) + + const output = stripAnsi(stdout) + stripAnsi(stderr) + expect(code).to.equal(0) + expect(output).to.contain('Asset transferred') + }) + }) + + describe('separate fee payer via -p', () => { + let payerKeypairPath: string + + before(async function () { + // Generate and fund a second keypair + const umi = createUmi(TEST_RPC).use(mplCore()).use(mplToolbox()) + const keypairData = JSON.parse(fs.readFileSync(KEYPAIR_PATH, 'utf-8')) + const mainKp = umi.eddsa.createKeypairFromSecretKey(new Uint8Array(keypairData)) + umi.use(keypairIdentity(mainKp)) + + const newKp = generateSigner(umi) + payerKeypairPath = path.join(os.tmpdir(), `mplx-test-payer-${Date.now()}.json`) + fs.writeFileSync(payerKeypairPath, JSON.stringify(Array.from(newKp.secretKey))) + tempFiles.push(payerKeypairPath) + + await umi.rpc.airdrop(newKp.publicKey, { basisPoints: 2_000_000_000n, identifier: 'SOL', decimals: 9 }) + await new Promise(resolve => setTimeout(resolve, 2000)) + }) + + it('transfers SOL from PDA with a different wallet paying fees', async function () { + const { stdout, stderr, code } = await runCliWithConfig( + ['toolbox', 'sol', 'transfer', '0.01', ownerAddress, '-p', payerKeypairPath], + configPath, + ) + + const output = stripAnsi(stdout) + stripAnsi(stderr) + expect(code).to.equal(0) + expect(output).to.contain('SOL transferred successfully') + }) + }) +}) diff --git a/test/commands/core/core.execute.test.ts b/test/commands/core/core.execute.test.ts new file mode 100644 index 0000000..810da72 --- /dev/null +++ b/test/commands/core/core.execute.test.ts @@ -0,0 +1,48 @@ +import { expect } from 'chai' +import { runCli } from '../../runCli' +import { createCoreAsset, createCoreCollection, stripAnsi } from './corehelpers' + +const ASSET_SIGNER_PDA_PATTERN = /Signer PDA:\s+([a-zA-Z0-9]+)/ + +const extractSignerPda = (str: string) => { + const match = str.match(ASSET_SIGNER_PDA_PATTERN) + return match ? match[1] : null +} + +describe('core asset execute commands', function () { + this.timeout(120000) + + describe('info', () => { + it('shows the asset signer PDA address and balance', async function () { + const { assetId } = await createCoreAsset() + + const { stdout, stderr, code } = await runCli([ + 'core', 'asset', 'execute', 'info', assetId + ]) + + const cleanStderr = stripAnsi(stderr) + const cleanStdout = stripAnsi(stdout) + const output = cleanStdout + cleanStderr + + expect(code).to.equal(0) + expect(output).to.contain('Signer PDA:') + expect(output).to.contain('SOL Balance:') + + const signerPda = extractSignerPda(output) + expect(signerPda).to.match(/^[a-zA-Z0-9]+$/) + }) + + it('shows the signer PDA for an asset in a collection', async function () { + const { collectionId } = await createCoreCollection() + const { assetId } = await createCoreAsset(collectionId) + + const { stdout, stderr, code } = await runCli([ + 'core', 'asset', 'execute', 'info', assetId + ]) + + const output = stripAnsi(stdout) + stripAnsi(stderr) + expect(code).to.equal(0) + expect(output).to.contain('Signer PDA:') + }) + }) +}) diff --git a/test/commands/toolbox/toolbox.raw.test.ts b/test/commands/toolbox/toolbox.raw.test.ts new file mode 100644 index 0000000..fa1f057 --- /dev/null +++ b/test/commands/toolbox/toolbox.raw.test.ts @@ -0,0 +1,60 @@ +import { expect } from 'chai' +import { runCli, KEYPAIR_PATH } from '../../runCli' +import { serializeInstruction } from '../../../src/lib/execute/deserializeInstruction.js' +import { stripAnsi } from '../core/corehelpers' +import { createUmi } from '@metaplex-foundation/umi-bundle-defaults' +import { keypairIdentity, publicKey } from '@metaplex-foundation/umi' +import fs from 'node:fs' + +describe('toolbox raw command', function () { + this.timeout(120000) + + let walletAddress: string + + before(async () => { + const umi = createUmi('http://127.0.0.1:8899') + const keypairData = JSON.parse(fs.readFileSync(KEYPAIR_PATH, 'utf-8')) + const kp = umi.eddsa.createKeypairFromSecretKey(new Uint8Array(keypairData)) + umi.use(keypairIdentity(kp)) + walletAddress = kp.publicKey.toString() + }) + + it('executes a raw SOL transfer instruction', async function () { + const destination = 'TESTfCYwTPxME2cAnPcKvvF5xdPah3PY7naYQEP2kkx' + const lamports = 10000n + + const data = new Uint8Array(12) + const view = new DataView(data.buffer) + view.setUint32(0, 2, true) + view.setBigUint64(4, lamports, true) + + const instruction = { + programId: publicKey('11111111111111111111111111111111'), + keys: [ + { pubkey: publicKey(walletAddress), isSigner: true, isWritable: true }, + { pubkey: publicKey(destination), isSigner: false, isWritable: true }, + ], + data, + } + + const serialized = serializeInstruction(instruction) + + const { stdout, stderr, code } = await runCli([ + 'toolbox', 'raw', '--instruction', serialized + ]) + + const output = stripAnsi(stdout) + stripAnsi(stderr) + expect(code).to.equal(0) + expect(output).to.contain('Executed 1 instruction(s)') + expect(output).to.contain('Signature:') + }) + + it('fails when no instructions are provided', async function () { + try { + await runCli(['toolbox', 'raw']) + expect.fail('Expected command to fail without instructions') + } catch (error: any) { + expect(error.message).to.contain('You must provide instructions via --instruction or --stdin') + } + }) +}) diff --git a/test/lib/deserializeInstruction.test.ts b/test/lib/deserializeInstruction.test.ts new file mode 100644 index 0000000..db3569b --- /dev/null +++ b/test/lib/deserializeInstruction.test.ts @@ -0,0 +1,97 @@ +import { expect } from 'chai' +import { Instruction, PublicKey } from '@metaplex-foundation/umi' +import { deserializeInstruction, serializeInstruction } from '../../src/lib/execute/deserializeInstruction.js' + +describe('deserializeInstruction / serializeInstruction', () => { + const SYSTEM_PROGRAM = '11111111111111111111111111111111' as PublicKey + const ACCOUNT_A = 'TESTfCYwTPxME2cAnPcKvvF5xdPah3PY7naYQEP2kkx' as PublicKey + const ACCOUNT_B = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA' as PublicKey + + it('roundtrips a simple instruction with no accounts and no data', () => { + const ix: Instruction = { + programId: SYSTEM_PROGRAM, + keys: [], + data: new Uint8Array(0), + } + + const serialized = serializeInstruction(ix) + const deserialized = deserializeInstruction(serialized) + + expect(deserialized.programId).to.equal(ix.programId) + expect(deserialized.keys).to.deep.equal([]) + expect(deserialized.data).to.deep.equal(new Uint8Array(0)) + }) + + it('roundtrips an instruction with accounts and data', () => { + const ix: Instruction = { + programId: SYSTEM_PROGRAM, + keys: [ + { pubkey: ACCOUNT_A, isSigner: true, isWritable: true }, + { pubkey: ACCOUNT_B, isSigner: false, isWritable: true }, + ], + data: new Uint8Array([2, 0, 0, 0, 0x40, 0x42, 0x0f, 0, 0, 0, 0, 0]), + } + + const serialized = serializeInstruction(ix) + const deserialized = deserializeInstruction(serialized) + + expect(deserialized.programId).to.equal(ix.programId) + expect(deserialized.keys.length).to.equal(2) + + expect(deserialized.keys[0].pubkey).to.equal(ACCOUNT_A) + expect(deserialized.keys[0].isSigner).to.equal(true) + expect(deserialized.keys[0].isWritable).to.equal(true) + + expect(deserialized.keys[1].pubkey).to.equal(ACCOUNT_B) + expect(deserialized.keys[1].isSigner).to.equal(false) + expect(deserialized.keys[1].isWritable).to.equal(true) + + expect(deserialized.data).to.deep.equal(ix.data) + }) + + it('roundtrips an instruction with signer-only (non-writable) account', () => { + const ix: Instruction = { + programId: SYSTEM_PROGRAM, + keys: [ + { pubkey: ACCOUNT_A, isSigner: true, isWritable: false }, + ], + data: new Uint8Array([1, 2, 3]), + } + + const serialized = serializeInstruction(ix) + const deserialized = deserializeInstruction(serialized) + + expect(deserialized.keys[0].isSigner).to.equal(true) + expect(deserialized.keys[0].isWritable).to.equal(false) + }) + + it('roundtrips an instruction with writable non-signer account', () => { + const ix: Instruction = { + programId: SYSTEM_PROGRAM, + keys: [ + { pubkey: ACCOUNT_A, isSigner: false, isWritable: true }, + ], + data: new Uint8Array([]), + } + + const serialized = serializeInstruction(ix) + const deserialized = deserializeInstruction(serialized) + + expect(deserialized.keys[0].isSigner).to.equal(false) + expect(deserialized.keys[0].isWritable).to.equal(true) + }) + + it('throws on data that is too short', () => { + const tooShort = Buffer.alloc(10).toString('base64') + expect(() => deserializeInstruction(tooShort)).to.throw('Instruction data too short') + }) + + it('throws on truncated account data', () => { + // Valid program ID (32 bytes) + 1 account count + incomplete account data + const buffer = Buffer.alloc(34 + 10) + buffer.writeUInt16LE(1, 32) // 1 account but not enough data for it + const b64 = buffer.toString('base64') + + expect(() => deserializeInstruction(b64)).to.throw('Unexpected end of data') + }) +}) diff --git a/test/runCli.ts b/test/runCli.ts index 2ec0e1b..866ebe1 100644 --- a/test/runCli.ts +++ b/test/runCli.ts @@ -1,12 +1,14 @@ import { spawn } from 'child_process' import { join } from 'path' -const CLI_PATH = join(process.cwd(), 'bin', 'run.js') +export const CLI_PATH = join(process.cwd(), 'bin', 'run.js') +export const TEST_RPC = 'http://127.0.0.1:8899' +export const KEYPAIR_PATH = join(process.cwd(), 'test-files', 'key.json') export const runCli = (args: string[], stdin?: string[]): Promise<{ stdout: string; stderr: string; code: number }> => { return new Promise((resolve, reject) => { // console.log('Spawning CLI process with args:', args) - const child = spawn('node', [CLI_PATH, ...args, '-r', 'http://127.0.0.1:8899', '-k', 'test-files/key.json'], { + const child = spawn('node', [CLI_PATH, ...args, '-r', TEST_RPC, '-k', KEYPAIR_PATH], { stdio: ['pipe', 'pipe', 'pipe'] })