From 50a75a088371a493339e2b4dc9df333917dcf757 Mon Sep 17 00:00:00 2001 From: MarkSackerberg <93528482+MarkSackerberg@users.noreply.github.com> Date: Tue, 17 Mar 2026 18:50:11 +0100 Subject: [PATCH 01/11] initial --- src/commands/core/asset/execute/index.ts | 17 ++ src/commands/core/asset/execute/raw.ts | 132 ++++++++++ src/commands/core/asset/execute/signer.ts | 55 ++++ .../core/asset/execute/transfer-asset.ts | 96 +++++++ .../core/asset/execute/transfer-sol.ts | 89 +++++++ .../core/asset/execute/transfer-token.ts | 108 ++++++++ src/lib/execute/deserializeInstruction.ts | 96 +++++++ test/commands/core/core.execute.test.ts | 243 ++++++++++++++++++ test/commands/core/executehelpers.ts | 69 +++++ test/lib/deserializeInstruction.test.ts | 97 +++++++ 10 files changed, 1002 insertions(+) create mode 100644 src/commands/core/asset/execute/index.ts create mode 100644 src/commands/core/asset/execute/raw.ts create mode 100644 src/commands/core/asset/execute/signer.ts create mode 100644 src/commands/core/asset/execute/transfer-asset.ts create mode 100644 src/commands/core/asset/execute/transfer-sol.ts create mode 100644 src/commands/core/asset/execute/transfer-token.ts create mode 100644 src/lib/execute/deserializeInstruction.ts create mode 100644 test/commands/core/core.execute.test.ts create mode 100644 test/commands/core/executehelpers.ts create mode 100644 test/lib/deserializeInstruction.test.ts diff --git a/src/commands/core/asset/execute/index.ts b/src/commands/core/asset/execute/index.ts new file mode 100644 index 0000000..7996306 --- /dev/null +++ b/src/commands/core/asset/execute/index.ts @@ -0,0 +1,17 @@ +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 signer ', + '<%= config.bin %> core asset execute transfer-sol ', + '<%= config.bin %> core asset execute transfer-token ', + '<%= config.bin %> core asset execute transfer-asset ', + '<%= config.bin %> core asset execute raw --instruction ', + ] + + public async run(): Promise { + const {args, flags} = await this.parse(CoreAssetExecute) + } +} diff --git a/src/commands/core/asset/execute/raw.ts b/src/commands/core/asset/execute/raw.ts new file mode 100644 index 0000000..9ee5bea --- /dev/null +++ b/src/commands/core/asset/execute/raw.ts @@ -0,0 +1,132 @@ +import { execute, fetchAsset, fetchCollection, findAssetSignerPda } from '@metaplex-foundation/mpl-core' +import { publicKey } from '@metaplex-foundation/umi' +import { Args, Flags } from '@oclif/core' +import ora from 'ora' + +import { generateExplorerUrl } from '../../../../explorers.js' +import { TransactionCommand } from '../../../../TransactionCommand.js' +import { txSignatureToString } from '../../../../lib/util.js' +import { deserializeInstruction } from '../../../../lib/execute/deserializeInstruction.js' + +export default class ExecuteRaw extends TransactionCommand { + static override description = `Execute arbitrary instructions signed by an asset's signer PDA. + +Instructions must be base64-encoded serialized Solana instructions. +Each instruction should be constructed with the asset's signer PDA as the signer. + +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 args = { + assetId: Args.string({ description: 'Asset whose signer PDA will sign the instructions', required: true }), + } + + 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 { args, flags } = await this.parse(ExecuteRaw) + 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('Fetching asset...').start() + + try { + const assetPubkey = publicKey(args.assetId) + const asset = await fetchAsset(umi, assetPubkey) + + let collection + if (asset.updateAuthority.type === 'Collection' && asset.updateAuthority.address) { + collection = await fetchCollection(umi, asset.updateAuthority.address) + } + + const [assetSignerPda] = findAssetSignerPda(umi, { asset: assetPubkey }) + + spinner.text = 'Deserializing instructions...' + + 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)...` + + const result = await execute(umi, { + asset, + collection, + instructions, + }).sendAndConfirm(umi) + + const signature = txSignatureToString(result.signature) + const explorerUrl = generateExplorerUrl(explorer, chain, signature, 'transaction') + + spinner.succeed(`Executed ${instructions.length} instruction(s) via asset signer`) + + this.logSuccess( + `-------------------------------- + Asset: ${args.assetId} + Signer PDA: ${assetSignerPda.toString()} + Instructions: ${instructions.length} + Signature: ${signature} +--------------------------------` + ) + this.log(explorerUrl) + + return { + asset: args.assetId, + signerPda: assetSignerPda.toString(), + instructionCount: instructions.length, + signature, + explorer: explorerUrl, + } + } catch (error) { + spinner.fail('Failed to execute instructions') + throw error + } + } +} diff --git a/src/commands/core/asset/execute/signer.ts b/src/commands/core/asset/execute/signer.ts new file mode 100644 index 0000000..511c4e0 --- /dev/null +++ b/src/commands/core/asset/execute/signer.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 ExecuteSigner 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(ExecuteSigner) + 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/execute/transfer-asset.ts b/src/commands/core/asset/execute/transfer-asset.ts new file mode 100644 index 0000000..256fd64 --- /dev/null +++ b/src/commands/core/asset/execute/transfer-asset.ts @@ -0,0 +1,96 @@ +import { execute, fetchAsset, fetchCollection, findAssetSignerPda, transfer } from '@metaplex-foundation/mpl-core' +import { createNoopSigner, publicKey } from '@metaplex-foundation/umi' +import { Args } from '@oclif/core' +import ora from 'ora' + +import { generateExplorerUrl } from '../../../../explorers.js' +import { TransactionCommand } from '../../../../TransactionCommand.js' +import { txSignatureToString } from '../../../../lib/util.js' + +export default class ExecuteTransferAsset extends TransactionCommand { + static override description = 'Transfer a Core Asset owned by an asset\'s signer PDA to a new owner' + + static override examples = [ + '<%= config.bin %> <%= command.id %> ', + ] + + static override args = { + assetId: Args.string({ description: 'Asset whose signer PDA owns the target asset', required: true }), + targetAssetId: Args.string({ description: 'Asset to transfer (must be owned by the signer PDA)', required: true }), + newOwner: Args.string({ description: 'New owner of the target asset', required: true }), + } + + public async run(): Promise { + const { args } = await this.parse(ExecuteTransferAsset) + const { umi, explorer, chain } = this.context + + const spinner = ora('Fetching assets...').start() + + try { + const signingAssetPubkey = publicKey(args.assetId) + const signingAsset = await fetchAsset(umi, signingAssetPubkey) + + let signingCollection + if (signingAsset.updateAuthority.type === 'Collection' && signingAsset.updateAuthority.address) { + signingCollection = await fetchCollection(umi, signingAsset.updateAuthority.address) + } + + const targetAssetPubkey = publicKey(args.targetAssetId) + const targetAsset = await fetchAsset(umi, targetAssetPubkey) + + let targetCollection + if (targetAsset.updateAuthority.type === 'Collection' && targetAsset.updateAuthority.address) { + targetCollection = await fetchCollection(umi, targetAsset.updateAuthority.address) + } + + const [assetSignerPda] = findAssetSignerPda(umi, { asset: signingAssetPubkey }) + + // Verify the target asset is owned by the signer PDA + if (targetAsset.owner.toString() !== assetSignerPda.toString()) { + spinner.fail('Transfer failed') + this.error(`Target asset is not owned by the asset signer PDA.\nExpected owner: ${assetSignerPda.toString()}\nActual owner: ${targetAsset.owner.toString()}`) + } + + const transferIx = transfer(umi, { + asset: targetAsset, + collection: targetCollection, + newOwner: publicKey(args.newOwner), + authority: createNoopSigner(assetSignerPda), + }) + + spinner.text = 'Executing asset transfer...' + + const result = await execute(umi, { + asset: signingAsset, + collection: signingCollection, + instructions: transferIx, + }).sendAndConfirm(umi) + + const signature = txSignatureToString(result.signature) + const explorerUrl = generateExplorerUrl(explorer, chain, signature, 'transaction') + + spinner.succeed('Asset transferred from signer PDA') + + this.logSuccess( + `-------------------------------- + Signing Asset: ${args.assetId} + Target Asset: ${args.targetAssetId} + New Owner: ${args.newOwner} + Signature: ${signature} +--------------------------------` + ) + this.log(explorerUrl) + + return { + signingAsset: args.assetId, + targetAsset: args.targetAssetId, + newOwner: args.newOwner, + signature, + explorer: explorerUrl, + } + } catch (error) { + spinner.fail('Failed to execute asset transfer') + throw error + } + } +} diff --git a/src/commands/core/asset/execute/transfer-sol.ts b/src/commands/core/asset/execute/transfer-sol.ts new file mode 100644 index 0000000..7d7fcb3 --- /dev/null +++ b/src/commands/core/asset/execute/transfer-sol.ts @@ -0,0 +1,89 @@ +import { execute, fetchAsset, fetchCollection, findAssetSignerPda } from '@metaplex-foundation/mpl-core' +import { transferSol } from '@metaplex-foundation/mpl-toolbox' +import { createNoopSigner, publicKey, sol } from '@metaplex-foundation/umi' +import { Args } from '@oclif/core' +import ora from 'ora' + +import { generateExplorerUrl } from '../../../../explorers.js' +import { TransactionCommand } from '../../../../TransactionCommand.js' +import { txSignatureToString } from '../../../../lib/util.js' + +export default class ExecuteTransferSol extends TransactionCommand { + static override description = 'Transfer SOL from an asset\'s signer PDA to a destination address' + + static override examples = [ + '<%= config.bin %> <%= command.id %> 0.5 ', + ] + + static override args = { + assetId: Args.string({ description: 'Asset whose signer PDA holds the SOL', required: true }), + amount: Args.string({ description: 'Amount of SOL to transfer', required: true }), + destination: Args.string({ description: 'Destination address', required: true }), + } + + public async run(): Promise { + const { args } = await this.parse(ExecuteTransferSol) + const { umi, explorer, chain } = this.context + + const amountInSol = parseFloat(args.amount) + if (isNaN(amountInSol) || amountInSol <= 0) { + this.error('Amount must be a positive number') + } + + const spinner = ora('Fetching asset...').start() + + try { + const assetPubkey = publicKey(args.assetId) + const asset = await fetchAsset(umi, assetPubkey) + + let collection + if (asset.updateAuthority.type === 'Collection' && asset.updateAuthority.address) { + collection = await fetchCollection(umi, asset.updateAuthority.address) + } + + const [assetSignerPda] = findAssetSignerPda(umi, { asset: assetPubkey }) + + const transferSolIx = transferSol(umi, { + source: createNoopSigner(assetSignerPda), + destination: publicKey(args.destination), + amount: sol(amountInSol), + }) + + spinner.text = 'Executing transfer...' + + const result = await execute(umi, { + asset, + collection, + instructions: transferSolIx, + }).sendAndConfirm(umi) + + const signature = txSignatureToString(result.signature) + const explorerUrl = generateExplorerUrl(explorer, chain, signature, 'transaction') + + spinner.succeed('SOL transferred from asset signer') + + this.logSuccess( + `-------------------------------- + Asset: ${args.assetId} + Signer PDA: ${assetSignerPda.toString()} + Amount: ${amountInSol} SOL + Destination: ${args.destination} + Signature: ${signature} +--------------------------------` + ) + this.log(explorerUrl) + + return { + asset: args.assetId, + signerPda: assetSignerPda.toString(), + amount: amountInSol, + destination: args.destination, + signature, + explorer: explorerUrl, + } + } catch (error) { + spinner.fail('Failed to execute SOL transfer') + throw error + } + } +} diff --git a/src/commands/core/asset/execute/transfer-token.ts b/src/commands/core/asset/execute/transfer-token.ts new file mode 100644 index 0000000..a882970 --- /dev/null +++ b/src/commands/core/asset/execute/transfer-token.ts @@ -0,0 +1,108 @@ +import { execute, fetchAsset, fetchCollection, findAssetSignerPda } from '@metaplex-foundation/mpl-core' +import { createAssociatedToken, findAssociatedTokenPda, transferTokens } from '@metaplex-foundation/mpl-toolbox' +import { createNoopSigner, publicKey } from '@metaplex-foundation/umi' +import { Args } from '@oclif/core' +import ora from 'ora' + +import { generateExplorerUrl } from '../../../../explorers.js' +import { TransactionCommand } from '../../../../TransactionCommand.js' +import { txSignatureToString } from '../../../../lib/util.js' + +export default class ExecuteTransferToken extends TransactionCommand { + static override description = 'Transfer SPL tokens from an asset\'s signer PDA to a destination address' + + static override examples = [ + '<%= config.bin %> <%= command.id %> 1000 ', + ] + + static override args = { + assetId: Args.string({ description: 'Asset whose signer PDA holds the tokens', required: true }), + mint: Args.string({ description: 'Token mint address', required: true }), + amount: Args.integer({ description: 'Amount to transfer in smallest unit (e.g., lamports for wrapped SOL)', required: true }), + destination: Args.string({ description: 'Destination wallet address', required: true }), + } + + public async run(): Promise { + const { args } = await this.parse(ExecuteTransferToken) + const { umi, explorer, chain } = this.context + + const spinner = ora('Fetching asset...').start() + + try { + const assetPubkey = publicKey(args.assetId) + const asset = await fetchAsset(umi, assetPubkey) + + let collection + if (asset.updateAuthority.type === 'Collection' && asset.updateAuthority.address) { + collection = await fetchCollection(umi, asset.updateAuthority.address) + } + + const [assetSignerPda] = findAssetSignerPda(umi, { asset: assetPubkey }) + const mintPubkey = publicKey(args.mint) + const destinationPubkey = publicKey(args.destination) + + // Create the destination token account if it doesn't exist, then transfer + const createAtaIx = createAssociatedToken(umi, { + mint: mintPubkey, + owner: destinationPubkey, + }) + + const transferTokensIx = transferTokens(umi, { + source: findAssociatedTokenPda(umi, { + mint: mintPubkey, + owner: assetSignerPda, + }), + destination: findAssociatedTokenPda(umi, { + mint: mintPubkey, + owner: destinationPubkey, + }), + authority: createNoopSigner(assetSignerPda), + amount: args.amount, + }) + + // Collect all instructions as a flat array for the execute wrapper + const instructions = [ + ...createAtaIx.getInstructions(), + ...transferTokensIx.getInstructions(), + ] + + spinner.text = 'Executing token transfer...' + + const result = await execute(umi, { + asset, + collection, + instructions, + }).sendAndConfirm(umi) + + const signature = txSignatureToString(result.signature) + const explorerUrl = generateExplorerUrl(explorer, chain, signature, 'transaction') + + spinner.succeed('Tokens transferred from asset signer') + + this.logSuccess( + `-------------------------------- + Asset: ${args.assetId} + Signer PDA: ${assetSignerPda.toString()} + Mint: ${args.mint} + Amount: ${args.amount} + Destination: ${args.destination} + Signature: ${signature} +--------------------------------` + ) + this.log(explorerUrl) + + return { + asset: args.assetId, + signerPda: assetSignerPda.toString(), + mint: args.mint, + amount: args.amount, + destination: args.destination, + signature, + explorer: explorerUrl, + } + } catch (error) { + spinner.fail('Failed to execute token transfer') + throw error + } + } +} 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/test/commands/core/core.execute.test.ts b/test/commands/core/core.execute.test.ts new file mode 100644 index 0000000..58204a3 --- /dev/null +++ b/test/commands/core/core.execute.test.ts @@ -0,0 +1,243 @@ +import { expect } from 'chai' +import { runCli } from '../../runCli' +import { createCoreAsset, createCoreCollection, extractAssetId, stripAnsi } from './corehelpers' +import { createAndFundToken } from './executehelpers' +import { serializeInstruction } from '../../../src/lib/execute/deserializeInstruction.js' + +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) + + before(async () => { + await runCli(['toolbox', 'sol', 'airdrop', '100', 'TESTfCYwTPxME2cAnPcKvvF5xdPah3PY7naYQEP2kkx']) + await new Promise(resolve => setTimeout(resolve, 10000)) + }) + + describe('signer', () => { + it('shows the asset signer PDA address and balance', async function () { + const { assetId } = await createCoreAsset() + + const { stdout, stderr, code } = await runCli([ + 'core', 'asset', 'execute', 'signer', 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', 'signer', assetId + ]) + + const output = stripAnsi(stdout) + stripAnsi(stderr) + expect(code).to.equal(0) + expect(output).to.contain('Signer PDA:') + }) + }) + + describe('transfer-sol', () => { + it('transfers SOL from the asset signer PDA', async function () { + const { assetId } = await createCoreAsset() + + // Get the signer PDA + const { stdout: signerOut, stderr: signerErr } = await runCli([ + 'core', 'asset', 'execute', 'signer', assetId + ]) + const signerPda = extractSignerPda(stripAnsi(signerOut) + stripAnsi(signerErr)) + expect(signerPda).to.be.ok + + // Fund the signer PDA + await runCli(['toolbox', 'sol', 'transfer', '0.1', signerPda!]) + await new Promise(resolve => setTimeout(resolve, 5000)) + + // Transfer SOL from the signer PDA + const destination = 'TESTfCYwTPxME2cAnPcKvvF5xdPah3PY7naYQEP2kkx' + const { stdout, stderr, code } = await runCli([ + 'core', 'asset', 'execute', 'transfer-sol', assetId, '0.01', destination + ]) + + const output = stripAnsi(stdout) + stripAnsi(stderr) + expect(code).to.equal(0) + expect(output).to.contain('SOL transferred from asset signer') + expect(output).to.contain('Signature:') + }) + + it('fails with an invalid amount', async function () { + const { assetId } = await createCoreAsset() + + try { + await runCli([ + 'core', 'asset', 'execute', 'transfer-sol', assetId, '-1', 'TESTfCYwTPxME2cAnPcKvvF5xdPah3PY7naYQEP2kkx' + ]) + expect.fail('Expected command to fail with negative amount') + } catch (error: any) { + expect(error.message).to.contain('Amount must be a positive number') + } + }) + }) + + describe('transfer-token', () => { + it('transfers SPL tokens from the asset signer PDA', async function () { + const { assetId } = await createCoreAsset() + + // Get the signer PDA + const { stdout: signerOut, stderr: signerErr } = await runCli([ + 'core', 'asset', 'execute', 'signer', assetId + ]) + const signerPda = extractSignerPda(stripAnsi(signerOut) + stripAnsi(signerErr)) + expect(signerPda).to.be.ok + + // Create a fungible token and mint 1000 tokens to the asset signer PDA + const mintAddress = await createAndFundToken(signerPda!, 1000, 0) + await new Promise(resolve => setTimeout(resolve, 5000)) + + // Transfer 100 tokens from the signer PDA to destination + const destination = 'TESTfCYwTPxME2cAnPcKvvF5xdPah3PY7naYQEP2kkx' + const { stdout, stderr, code } = await runCli([ + 'core', 'asset', 'execute', 'transfer-token', + assetId, mintAddress, '100', destination + ]) + + const output = stripAnsi(stdout) + stripAnsi(stderr) + expect(code).to.equal(0) + expect(output).to.contain('Tokens transferred from asset signer') + expect(output).to.contain('Signature:') + }) + }) + + describe('transfer-asset', () => { + it('transfers an asset owned by the signer PDA to a new owner', async function () { + // Create the "signing" asset (the one whose PDA will own another asset) + const { assetId: signingAssetId } = await createCoreAsset() + + // Get the signer PDA + const { stdout: signerOut, stderr: signerErr } = await runCli([ + 'core', 'asset', 'execute', 'signer', signingAssetId + ]) + const signerPda = extractSignerPda(stripAnsi(signerOut) + stripAnsi(signerErr)) + expect(signerPda).to.be.ok + + // Create a second asset owned by the signer PDA + const { stdout: createOut, stderr: createErr, code: createCode } = await runCli([ + 'core', 'asset', 'create', + '--name', 'Owned by PDA', + '--uri', 'https://example.com/pda-owned', + '--owner', signerPda!, + ], ['\n']) + expect(createCode).to.equal(0) + + const targetAssetId = extractAssetId(stripAnsi(createOut)) || extractAssetId(stripAnsi(createErr)) + expect(targetAssetId).to.be.ok + + // Transfer the target asset from the signer PDA to a new owner + const newOwner = 'TESTfCYwTPxME2cAnPcKvvF5xdPah3PY7naYQEP2kkx' + const { stdout, stderr, code } = await runCli([ + 'core', 'asset', 'execute', 'transfer-asset', signingAssetId, targetAssetId!, newOwner + ]) + + const output = stripAnsi(stdout) + stripAnsi(stderr) + expect(code).to.equal(0) + expect(output).to.contain('Asset transferred from signer PDA') + expect(output).to.contain('Signature:') + + // Verify ownership changed + const { stdout: fetchOut } = await runCli(['core', 'asset', 'fetch', targetAssetId!]) + expect(stripAnsi(fetchOut)).to.contain(newOwner) + }) + + it('fails when target asset is not owned by the signer PDA', async function () { + const { assetId: signingAssetId } = await createCoreAsset() + const { assetId: otherAssetId } = await createCoreAsset() + + try { + await runCli([ + 'core', 'asset', 'execute', 'transfer-asset', + signingAssetId, otherAssetId, 'TESTfCYwTPxME2cAnPcKvvF5xdPah3PY7naYQEP2kkx' + ]) + expect.fail('Expected command to fail when target is not owned by PDA') + } catch (error: any) { + expect(error.message).to.contain('not owned by the asset signer PDA') + } + }) + }) + + describe('raw', () => { + it('executes a raw SOL transfer instruction via --instruction', async function () { + const { assetId } = await createCoreAsset() + + // Get the signer PDA + const { stdout: signerOut, stderr: signerErr } = await runCli([ + 'core', 'asset', 'execute', 'signer', assetId + ]) + const signerPda = extractSignerPda(stripAnsi(signerOut) + stripAnsi(signerErr)) + expect(signerPda).to.be.ok + + // Fund the signer PDA + await runCli(['toolbox', 'sol', 'transfer', '0.1', signerPda!]) + await new Promise(resolve => setTimeout(resolve, 5000)) + + // Build a system program Transfer instruction manually: + // System program transfer = discriminator 2 (u32 LE) + lamports (u64 LE) + const SYSTEM_PROGRAM = '11111111111111111111111111111111' + const destination = 'TESTfCYwTPxME2cAnPcKvvF5xdPah3PY7naYQEP2kkx' + const lamports = 10000n // 0.00001 SOL + + const data = new Uint8Array(12) + const view = new DataView(data.buffer) + view.setUint32(0, 2, true) // Transfer instruction discriminator + view.setBigUint64(4, lamports, true) // Amount in lamports + + const instruction = { + programId: SYSTEM_PROGRAM, + keys: [ + { pubkey: signerPda!, isSigner: true, isWritable: true }, + { pubkey: destination, isSigner: false, isWritable: true }, + ], + data, + } + + const serialized = serializeInstruction(instruction as any) + + const { stdout, stderr, code } = await runCli([ + 'core', 'asset', 'execute', 'raw', assetId, + '--instruction', serialized + ]) + + const output = stripAnsi(stdout) + stripAnsi(stderr) + expect(code).to.equal(0) + expect(output).to.contain('Executed 1 instruction(s) via asset signer') + expect(output).to.contain('Signature:') + }) + + it('fails when no instructions are provided', async function () { + const { assetId } = await createCoreAsset() + + try { + await runCli([ + 'core', 'asset', 'execute', 'raw', assetId + ]) + 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/commands/core/executehelpers.ts b/test/commands/core/executehelpers.ts new file mode 100644 index 0000000..b5de6d8 --- /dev/null +++ b/test/commands/core/executehelpers.ts @@ -0,0 +1,69 @@ +import { createUmi } from '@metaplex-foundation/umi-bundle-defaults' +import { createFungible, mplTokenMetadata } from '@metaplex-foundation/mpl-token-metadata' +import { mplCore } from '@metaplex-foundation/mpl-core' +import { + createAssociatedToken, + findAssociatedTokenPda, + mintTokensTo, + mplToolbox, +} from '@metaplex-foundation/mpl-toolbox' +import { + generateSigner, + keypairIdentity, + percentAmount, + publicKey, +} from '@metaplex-foundation/umi' +import fs from 'node:fs' +import path from 'node:path' + +const TEST_RPC = 'http://127.0.0.1:8899' +const KEYPAIR_PATH = path.join(process.cwd(), 'test-files', 'key.json') + +/** + * Creates a umi instance configured with the test keypair and local validator. + */ +export function createTestUmi() { + const umi = createUmi(TEST_RPC) + .use(mplCore()) + .use(mplToolbox()) + .use(mplTokenMetadata()) + const keypairData = JSON.parse(fs.readFileSync(KEYPAIR_PATH, 'utf-8')) + const keypair = umi.eddsa.createKeypairFromSecretKey(new Uint8Array(keypairData)) + umi.use(keypairIdentity(keypair)) + return umi +} + +/** + * Creates a fungible SPL token and mints tokens to a specified owner. + * Returns the mint address. + */ +export async function createAndFundToken( + owner: string, + amount: number, + decimals: number = 0, +): Promise { + const umi = createTestUmi() + const mint = generateSigner(umi) + const ownerPubkey = publicKey(owner) + + const tx = createFungible(umi, { + mint, + name: 'Test Token', + symbol: 'TST', + uri: 'https://example.com/test-token.json', + decimals, + sellerFeeBasisPoints: percentAmount(0), + }) + .add(createAssociatedToken(umi, { + mint: mint.publicKey, + owner: ownerPubkey, + })) + .add(mintTokensTo(umi, { + mint: mint.publicKey, + token: findAssociatedTokenPda(umi, { mint: mint.publicKey, owner: ownerPubkey }), + amount, + })) + + await tx.sendAndConfirm(umi) + return mint.publicKey.toString() +} 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') + }) +}) From 168a5844e51fc5d2c4457aea82412ac77f9f1a74 Mon Sep 17 00:00:00 2001 From: MarkSackerberg <93528482+MarkSackerberg@users.noreply.github.com> Date: Wed, 18 Mar 2026 15:42:39 +0100 Subject: [PATCH 02/11] change flags --- src/commands/core/asset/execute/index.ts | 6 ++-- .../core/asset/execute/transfer-asset.ts | 25 +++++++------- .../core/asset/execute/transfer-sol.ts | 21 +++++++----- .../core/asset/execute/transfer-token.ts | 33 ++++++++++--------- test/commands/core/core.execute.test.ts | 17 ++++++---- 5 files changed, 57 insertions(+), 45 deletions(-) diff --git a/src/commands/core/asset/execute/index.ts b/src/commands/core/asset/execute/index.ts index 7996306..ecb63aa 100644 --- a/src/commands/core/asset/execute/index.ts +++ b/src/commands/core/asset/execute/index.ts @@ -5,9 +5,9 @@ export default class CoreAssetExecute extends Command { static override examples = [ '<%= config.bin %> core asset execute signer ', - '<%= config.bin %> core asset execute transfer-sol ', - '<%= config.bin %> core asset execute transfer-token ', - '<%= config.bin %> core asset execute transfer-asset ', + '<%= config.bin %> core asset execute transfer-sol --amount 0.5 --destination
', + '<%= config.bin %> core asset execute transfer-token --mint --amount 1000 --destination
', + '<%= config.bin %> core asset execute transfer-asset --asset --new-owner
', '<%= config.bin %> core asset execute raw --instruction ', ] diff --git a/src/commands/core/asset/execute/transfer-asset.ts b/src/commands/core/asset/execute/transfer-asset.ts index 256fd64..e372b5f 100644 --- a/src/commands/core/asset/execute/transfer-asset.ts +++ b/src/commands/core/asset/execute/transfer-asset.ts @@ -1,6 +1,6 @@ import { execute, fetchAsset, fetchCollection, findAssetSignerPda, transfer } from '@metaplex-foundation/mpl-core' import { createNoopSigner, publicKey } from '@metaplex-foundation/umi' -import { Args } from '@oclif/core' +import { Args, Flags } from '@oclif/core' import ora from 'ora' import { generateExplorerUrl } from '../../../../explorers.js' @@ -11,17 +11,20 @@ export default class ExecuteTransferAsset extends TransactionCommand <%= command.id %> ', + '<%= config.bin %> <%= command.id %> --asset --new-owner
', ] static override args = { assetId: Args.string({ description: 'Asset whose signer PDA owns the target asset', required: true }), - targetAssetId: Args.string({ description: 'Asset to transfer (must be owned by the signer PDA)', required: true }), - newOwner: Args.string({ description: 'New owner of the target asset', required: true }), + } + + static override flags = { + asset: Flags.string({ description: 'Asset to transfer (must be owned by the signer PDA)', required: true }), + 'new-owner': Flags.string({ description: 'New owner of the target asset', required: true }), } public async run(): Promise { - const { args } = await this.parse(ExecuteTransferAsset) + const { args, flags } = await this.parse(ExecuteTransferAsset) const { umi, explorer, chain } = this.context const spinner = ora('Fetching assets...').start() @@ -35,7 +38,7 @@ export default class ExecuteTransferAsset extends TransactionCommand <%= command.id %> 0.5 ', + '<%= config.bin %> <%= command.id %> --amount 0.5 --destination
', ] static override args = { assetId: Args.string({ description: 'Asset whose signer PDA holds the SOL', required: true }), - amount: Args.string({ description: 'Amount of SOL to transfer', required: true }), - destination: Args.string({ description: 'Destination address', required: true }), + } + + static override flags = { + amount: Flags.string({ description: 'Amount of SOL to transfer', required: true }), + destination: Flags.string({ description: 'Destination address', required: true }), } public async run(): Promise { - const { args } = await this.parse(ExecuteTransferSol) + const { args, flags } = await this.parse(ExecuteTransferSol) const { umi, explorer, chain } = this.context - const amountInSol = parseFloat(args.amount) + const amountInSol = parseFloat(flags.amount) if (isNaN(amountInSol) || amountInSol <= 0) { this.error('Amount must be a positive number') } @@ -45,7 +48,7 @@ export default class ExecuteTransferSol extends TransactionCommand <%= command.id %> 1000 ', + '<%= config.bin %> <%= command.id %> --mint --amount 1000 --destination
', ] static override args = { assetId: Args.string({ description: 'Asset whose signer PDA holds the tokens', required: true }), - mint: Args.string({ description: 'Token mint address', required: true }), - amount: Args.integer({ description: 'Amount to transfer in smallest unit (e.g., lamports for wrapped SOL)', required: true }), - destination: Args.string({ description: 'Destination wallet address', required: true }), + } + + static override flags = { + mint: Flags.string({ description: 'Token mint address', required: true }), + amount: Flags.integer({ description: 'Amount to transfer in smallest unit (e.g., lamports for wrapped SOL)', required: true }), + destination: Flags.string({ description: 'Destination wallet address', required: true }), } public async run(): Promise { - const { args } = await this.parse(ExecuteTransferToken) + const { args, flags } = await this.parse(ExecuteTransferToken) const { umi, explorer, chain } = this.context const spinner = ora('Fetching asset...').start() @@ -38,8 +41,8 @@ export default class ExecuteTransferToken extends TransactionCommand Date: Wed, 18 Mar 2026 20:40:14 +0100 Subject: [PATCH 03/11] plugin approach --- src/commands/cm/create.ts | 9 +- src/commands/cm/withdraw.ts | 9 +- src/commands/config/wallets/add.ts | 135 ++++++++++++----- src/commands/config/wallets/list.ts | 24 ++- src/commands/config/wallets/new.ts | 2 +- src/commands/config/wallets/set.ts | 42 ++++-- src/commands/core/asset/execute/index.ts | 5 +- .../core/asset/execute/{signer.ts => info.ts} | 4 +- .../core/asset/execute/transfer-asset.ts | 99 ------------- .../core/asset/execute/transfer-sol.ts | 92 ------------ .../core/asset/execute/transfer-token.ts | 111 -------------- src/commands/core/asset/transfer.ts | 40 +++-- src/commands/core/asset/update.ts | 6 +- src/commands/core/collection/create.ts | 22 +-- src/commands/tm/create.ts | 5 +- src/commands/tm/transfer.ts | 12 +- src/commands/tm/update.ts | 9 +- src/commands/toolbox/sol/airdrop.ts | 3 +- src/commands/toolbox/sol/balance.ts | 3 +- src/commands/toolbox/sol/unwrap.ts | 5 +- src/commands/toolbox/sol/wrap.ts | 5 +- src/commands/toolbox/token/create.ts | 5 +- src/commands/toolbox/token/mint.ts | 3 +- src/commands/toolbox/token/transfer.ts | 3 +- src/lib/Context.ts | 77 +++++++++- src/lib/umi/assetSignerPlugin.ts | 43 ++++++ src/lib/umi/sendAndConfirm.ts | 12 +- src/lib/umi/wrapForAssetSigner.ts | 55 +++++++ test/commands/core/core.execute.test.ts | 140 +----------------- 29 files changed, 420 insertions(+), 560 deletions(-) rename src/commands/core/asset/execute/{signer.ts => info.ts} (91%) delete mode 100644 src/commands/core/asset/execute/transfer-asset.ts delete mode 100644 src/commands/core/asset/execute/transfer-sol.ts delete mode 100644 src/commands/core/asset/execute/transfer-token.ts create mode 100644 src/lib/umi/assetSignerPlugin.ts create mode 100644 src/lib/umi/wrapForAssetSigner.ts 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..81a2f47 100644 --- a/src/commands/config/wallets/add.ts +++ b/src/commands/config/wallets/add.ts @@ -1,80 +1,149 @@ -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 { findAssetSignerPda } from '@metaplex-foundation/mpl-core' +import { publicKey } from '@metaplex-foundation/umi' +import { createSignerFromPath, 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', + }), + payer: Flags.string({ + description: 'Default payer wallet name for gas fees (for asset-signer wallets)', + }), } 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 ', + '<%= config.bin %> <%= command.id %> vault --asset --payer my-wallet', ] 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 + const path = flags.config ?? getDefaultConfigPath() + const config = readConfig(path) + + if (!config.wallets) { + config.wallets = [] + } + + // 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.`) + } + + let wallet: WalletEntry + + if (flags.asset) { + // Asset-signer wallet + const assetPubkey = publicKey(flags.asset) + const [pdaPubkey] = findAssetSignerPda(DUMMY_UMI, { asset: assetPubkey }) + + // Validate payer reference if provided + if (flags.payer) { + const payerWallet = config.wallets.find(w => w.name === flags.payer) + if (!payerWallet) { + this.error(`Payer wallet '${flags.payer}' not found. Add it first with 'mplx config wallet add'.`) + } + if (payerWallet.type === 'asset-signer') { + this.error(`Payer wallet '${flags.payer}' is an asset-signer wallet. The payer must be a file or ledger wallet.`) + } + } + + const existingAddress = config.wallets.find((w) => w.address === pdaPubkey.toString()) + if (existingAddress) { + 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(), + ...(flags.payer ? { payer: flags.payer } : {}), + } + + config.wallets.push(wallet) + + const dir = dirname(path) + ensureDirectoryExists(dir) + writeJsonSync(path, config) + + this.log( + `✅ Asset-signer wallet '${args.name}' added!\n` + + ` Asset: ${flags.asset}\n` + + ` Signer PDA: ${pdaPubkey.toString()}\n` + + (flags.payer ? ` Payer: ${flags.payer}\n` : '') + + `\nUse 'mplx config wallet set ${args.name}' to make this your active wallet.` + ) + + return { + name: args.name, + type: 'asset-signer', + asset: flags.asset, + address: pdaPubkey.toString(), + payer: flags.payer, + } + } + + // 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}`) } - // 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.`) } - const path = flags.config ?? getDefaultConfigPath() - - const config = readConfig(path) - const signer = await createSignerFromPath(args.path) - 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 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.`) - } + 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((wallet) => wallet.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.`) - } + 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.`) } - config.wallets?.push({ + wallet = { name: args.name, - address: signer.publicKey, + address: signer.publicKey.toString(), path: args.path, - }) + } + + config.wallets.push(wallet) const dir = dirname(path) ensureDirectoryExists(dir) diff --git a/src/commands/config/wallets/list.ts b/src/commands/config/wallets/list.ts index 6cc5885..b129113 100644 --- a/src/commands/config/wallets/list.ts +++ b/src/commands/config/wallets/list.ts @@ -20,17 +20,27 @@ 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}`) + const typeLabel = wallet.type === 'asset-signer' ? ' [asset-signer]' : '' + this.log(` ${wallet.name}: ${wallet.address}${typeLabel}${marker}`) } 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..42cb20a 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,46 @@ 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 + 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 index ecb63aa..b270741 100644 --- a/src/commands/core/asset/execute/index.ts +++ b/src/commands/core/asset/execute/index.ts @@ -4,10 +4,7 @@ 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 signer ', - '<%= config.bin %> core asset execute transfer-sol --amount 0.5 --destination
', - '<%= config.bin %> core asset execute transfer-token --mint --amount 1000 --destination
', - '<%= config.bin %> core asset execute transfer-asset --asset --new-owner
', + '<%= config.bin %> core asset execute info ', '<%= config.bin %> core asset execute raw --instruction ', ] diff --git a/src/commands/core/asset/execute/signer.ts b/src/commands/core/asset/execute/info.ts similarity index 91% rename from src/commands/core/asset/execute/signer.ts rename to src/commands/core/asset/execute/info.ts index 511c4e0..1b29132 100644 --- a/src/commands/core/asset/execute/signer.ts +++ b/src/commands/core/asset/execute/info.ts @@ -5,7 +5,7 @@ import ora from 'ora' import { TransactionCommand } from '../../../../TransactionCommand.js' -export default class ExecuteSigner extends TransactionCommand { +export default class ExecuteInfo extends TransactionCommand { static override description = 'Show the asset signer PDA address and its SOL balance' static override examples = [ @@ -17,7 +17,7 @@ export default class ExecuteSigner extends TransactionCommand { - const { args } = await this.parse(ExecuteSigner) + const { args } = await this.parse(ExecuteInfo) const { umi } = this.context const spinner = ora('Fetching asset signer info...').start() diff --git a/src/commands/core/asset/execute/transfer-asset.ts b/src/commands/core/asset/execute/transfer-asset.ts deleted file mode 100644 index e372b5f..0000000 --- a/src/commands/core/asset/execute/transfer-asset.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { execute, fetchAsset, fetchCollection, findAssetSignerPda, transfer } from '@metaplex-foundation/mpl-core' -import { createNoopSigner, publicKey } from '@metaplex-foundation/umi' -import { Args, Flags } from '@oclif/core' -import ora from 'ora' - -import { generateExplorerUrl } from '../../../../explorers.js' -import { TransactionCommand } from '../../../../TransactionCommand.js' -import { txSignatureToString } from '../../../../lib/util.js' - -export default class ExecuteTransferAsset extends TransactionCommand { - static override description = 'Transfer a Core Asset owned by an asset\'s signer PDA to a new owner' - - static override examples = [ - '<%= config.bin %> <%= command.id %> --asset --new-owner
', - ] - - static override args = { - assetId: Args.string({ description: 'Asset whose signer PDA owns the target asset', required: true }), - } - - static override flags = { - asset: Flags.string({ description: 'Asset to transfer (must be owned by the signer PDA)', required: true }), - 'new-owner': Flags.string({ description: 'New owner of the target asset', required: true }), - } - - public async run(): Promise { - const { args, flags } = await this.parse(ExecuteTransferAsset) - const { umi, explorer, chain } = this.context - - const spinner = ora('Fetching assets...').start() - - try { - const signingAssetPubkey = publicKey(args.assetId) - const signingAsset = await fetchAsset(umi, signingAssetPubkey) - - let signingCollection - if (signingAsset.updateAuthority.type === 'Collection' && signingAsset.updateAuthority.address) { - signingCollection = await fetchCollection(umi, signingAsset.updateAuthority.address) - } - - const targetAssetPubkey = publicKey(flags.asset) - const targetAsset = await fetchAsset(umi, targetAssetPubkey) - - let targetCollection - if (targetAsset.updateAuthority.type === 'Collection' && targetAsset.updateAuthority.address) { - targetCollection = await fetchCollection(umi, targetAsset.updateAuthority.address) - } - - const [assetSignerPda] = findAssetSignerPda(umi, { asset: signingAssetPubkey }) - - // Verify the target asset is owned by the signer PDA - if (targetAsset.owner.toString() !== assetSignerPda.toString()) { - spinner.fail('Transfer failed') - this.error(`Target asset is not owned by the asset signer PDA.\nExpected owner: ${assetSignerPda.toString()}\nActual owner: ${targetAsset.owner.toString()}`) - } - - const transferIx = transfer(umi, { - asset: targetAsset, - collection: targetCollection, - newOwner: publicKey(flags['new-owner']), - authority: createNoopSigner(assetSignerPda), - }) - - spinner.text = 'Executing asset transfer...' - - const result = await execute(umi, { - asset: signingAsset, - collection: signingCollection, - instructions: transferIx, - }).sendAndConfirm(umi) - - const signature = txSignatureToString(result.signature) - const explorerUrl = generateExplorerUrl(explorer, chain, signature, 'transaction') - - spinner.succeed('Asset transferred from signer PDA') - - this.logSuccess( - `-------------------------------- - Signing Asset: ${args.assetId} - Target Asset: ${flags.asset} - New Owner: ${flags['new-owner']} - Signature: ${signature} ---------------------------------` - ) - this.log(explorerUrl) - - return { - signingAsset: args.assetId, - targetAsset: flags.asset, - newOwner: flags['new-owner'], - signature, - explorer: explorerUrl, - } - } catch (error) { - spinner.fail('Failed to execute asset transfer') - throw error - } - } -} diff --git a/src/commands/core/asset/execute/transfer-sol.ts b/src/commands/core/asset/execute/transfer-sol.ts deleted file mode 100644 index 03b5eea..0000000 --- a/src/commands/core/asset/execute/transfer-sol.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { execute, fetchAsset, fetchCollection, findAssetSignerPda } from '@metaplex-foundation/mpl-core' -import { transferSol } from '@metaplex-foundation/mpl-toolbox' -import { createNoopSigner, publicKey, sol } from '@metaplex-foundation/umi' -import { Args, Flags } from '@oclif/core' -import ora from 'ora' - -import { generateExplorerUrl } from '../../../../explorers.js' -import { TransactionCommand } from '../../../../TransactionCommand.js' -import { txSignatureToString } from '../../../../lib/util.js' - -export default class ExecuteTransferSol extends TransactionCommand { - static override description = 'Transfer SOL from an asset\'s signer PDA to a destination address' - - static override examples = [ - '<%= config.bin %> <%= command.id %> --amount 0.5 --destination
', - ] - - static override args = { - assetId: Args.string({ description: 'Asset whose signer PDA holds the SOL', required: true }), - } - - static override flags = { - amount: Flags.string({ description: 'Amount of SOL to transfer', required: true }), - destination: Flags.string({ description: 'Destination address', required: true }), - } - - public async run(): Promise { - const { args, flags } = await this.parse(ExecuteTransferSol) - const { umi, explorer, chain } = this.context - - const amountInSol = parseFloat(flags.amount) - if (isNaN(amountInSol) || amountInSol <= 0) { - this.error('Amount must be a positive number') - } - - const spinner = ora('Fetching asset...').start() - - try { - const assetPubkey = publicKey(args.assetId) - const asset = await fetchAsset(umi, assetPubkey) - - let collection - if (asset.updateAuthority.type === 'Collection' && asset.updateAuthority.address) { - collection = await fetchCollection(umi, asset.updateAuthority.address) - } - - const [assetSignerPda] = findAssetSignerPda(umi, { asset: assetPubkey }) - - const transferSolIx = transferSol(umi, { - source: createNoopSigner(assetSignerPda), - destination: publicKey(flags.destination), - amount: sol(amountInSol), - }) - - spinner.text = 'Executing transfer...' - - const result = await execute(umi, { - asset, - collection, - instructions: transferSolIx, - }).sendAndConfirm(umi) - - const signature = txSignatureToString(result.signature) - const explorerUrl = generateExplorerUrl(explorer, chain, signature, 'transaction') - - spinner.succeed('SOL transferred from asset signer') - - this.logSuccess( - `-------------------------------- - Asset: ${args.assetId} - Signer PDA: ${assetSignerPda.toString()} - Amount: ${amountInSol} SOL - Destination: ${flags.destination} - Signature: ${signature} ---------------------------------` - ) - this.log(explorerUrl) - - return { - asset: args.assetId, - signerPda: assetSignerPda.toString(), - amount: amountInSol, - destination: flags.destination, - signature, - explorer: explorerUrl, - } - } catch (error) { - spinner.fail('Failed to execute SOL transfer') - throw error - } - } -} diff --git a/src/commands/core/asset/execute/transfer-token.ts b/src/commands/core/asset/execute/transfer-token.ts deleted file mode 100644 index ac8eb41..0000000 --- a/src/commands/core/asset/execute/transfer-token.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { execute, fetchAsset, fetchCollection, findAssetSignerPda } from '@metaplex-foundation/mpl-core' -import { createAssociatedToken, findAssociatedTokenPda, transferTokens } from '@metaplex-foundation/mpl-toolbox' -import { createNoopSigner, publicKey } from '@metaplex-foundation/umi' -import { Args, Flags } from '@oclif/core' -import ora from 'ora' - -import { generateExplorerUrl } from '../../../../explorers.js' -import { TransactionCommand } from '../../../../TransactionCommand.js' -import { txSignatureToString } from '../../../../lib/util.js' - -export default class ExecuteTransferToken extends TransactionCommand { - static override description = 'Transfer SPL tokens from an asset\'s signer PDA to a destination address' - - static override examples = [ - '<%= config.bin %> <%= command.id %> --mint --amount 1000 --destination
', - ] - - static override args = { - assetId: Args.string({ description: 'Asset whose signer PDA holds the tokens', required: true }), - } - - static override flags = { - mint: Flags.string({ description: 'Token mint address', required: true }), - amount: Flags.integer({ description: 'Amount to transfer in smallest unit (e.g., lamports for wrapped SOL)', required: true }), - destination: Flags.string({ description: 'Destination wallet address', required: true }), - } - - public async run(): Promise { - const { args, flags } = await this.parse(ExecuteTransferToken) - const { umi, explorer, chain } = this.context - - const spinner = ora('Fetching asset...').start() - - try { - const assetPubkey = publicKey(args.assetId) - const asset = await fetchAsset(umi, assetPubkey) - - let collection - if (asset.updateAuthority.type === 'Collection' && asset.updateAuthority.address) { - collection = await fetchCollection(umi, asset.updateAuthority.address) - } - - const [assetSignerPda] = findAssetSignerPda(umi, { asset: assetPubkey }) - const mintPubkey = publicKey(flags.mint) - const destinationPubkey = publicKey(flags.destination) - - // Create the destination token account if it doesn't exist, then transfer - const createAtaIx = createAssociatedToken(umi, { - mint: mintPubkey, - owner: destinationPubkey, - }) - - const transferTokensIx = transferTokens(umi, { - source: findAssociatedTokenPda(umi, { - mint: mintPubkey, - owner: assetSignerPda, - }), - destination: findAssociatedTokenPda(umi, { - mint: mintPubkey, - owner: destinationPubkey, - }), - authority: createNoopSigner(assetSignerPda), - amount: flags.amount, - }) - - // Collect all instructions as a flat array for the execute wrapper - const instructions = [ - ...createAtaIx.getInstructions(), - ...transferTokensIx.getInstructions(), - ] - - spinner.text = 'Executing token transfer...' - - const result = await execute(umi, { - asset, - collection, - instructions, - }).sendAndConfirm(umi) - - const signature = txSignatureToString(result.signature) - const explorerUrl = generateExplorerUrl(explorer, chain, signature, 'transaction') - - spinner.succeed('Tokens transferred from asset signer') - - this.logSuccess( - `-------------------------------- - Asset: ${args.assetId} - Signer PDA: ${assetSignerPda.toString()} - Mint: ${flags.mint} - Amount: ${flags.amount} - Destination: ${flags.destination} - Signature: ${signature} ---------------------------------` - ) - this.log(explorerUrl) - - return { - asset: args.assetId, - signerPda: assetSignerPda.toString(), - mint: flags.mint, - amount: flags.amount, - destination: flags.destination, - signature, - explorer: explorerUrl, - } - } catch (error) { - spinner.fail('Failed to execute token transfer') - throw error - } - } -} diff --git a/src/commands/core/asset/transfer.ts b/src/commands/core/asset/transfer.ts index d161e02..5e78f97 100644 --- a/src/commands/core/asset/transfer.ts +++ b/src/commands/core/asset/transfer.ts @@ -5,6 +5,8 @@ import ora from 'ora' import { generateExplorerUrl } from '../../../explorers.js' import { TransactionCommand } from '../../../TransactionCommand.js' +import { getAssetSigner } from '../../../lib/umi/assetSignerPlugin.js' +import umiSendAndConfirmTransaction from '../../../lib/umi/sendAndConfirm.js' import { txSignatureToString } from '../../../lib/util.js' export default class AssetTransfer extends TransactionCommand { @@ -38,30 +40,36 @@ 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/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..1cab641 100644 --- a/src/commands/tm/transfer.ts +++ b/src/commands/tm/transfer.ts @@ -15,6 +15,8 @@ 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 { getEffectiveOwner } from '../../lib/umi/assetSignerPlugin.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.' @@ -65,7 +67,7 @@ export default class TmTransfer extends TransactionCommand { mint: asset.publicKey, token: findAssociatedTokenPda(umi, { mint: asset.publicKey, - owner: umi.identity.publicKey, + owner: getEffectiveOwner(umi), })[0], }) @@ -89,7 +91,7 @@ export default class TmTransfer extends TransactionCommand { transferIx = transferV1(umi, { mint: asset.publicKey, authority: umi.identity, - tokenOwner: umi.identity.publicKey, + tokenOwner: getEffectiveOwner(umi), destinationOwner, destinationToken: destinationToken[0], tokenStandard: TokenStandard.ProgrammableNonFungible, @@ -104,20 +106,20 @@ export default class TmTransfer extends TransactionCommand { transferIx = transferV1(umi, { mint: asset.publicKey, authority: umi.identity, - tokenOwner: umi.identity.publicKey, + tokenOwner: getEffectiveOwner(umi), destinationOwner, tokenStandard: unwrapOptionRecursively(asset.metadata.tokenStandard) || TokenStandard.NonFungible }) } - 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/sol/airdrop.ts b/src/commands/toolbox/sol/airdrop.ts index fe4bf57..0b8a291 100644 --- a/src/commands/toolbox/sol/airdrop.ts +++ b/src/commands/toolbox/sol/airdrop.ts @@ -3,6 +3,7 @@ import { Args } from '@oclif/core' import ora from 'ora' import { TransactionCommand } from '../../../TransactionCommand.js' import umiAirdrop from '../../../lib/toolbox/airdrop.js' +import { getEffectiveOwner } from '../../../lib/umi/assetSignerPlugin.js' @@ -49,7 +50,7 @@ export default class ToolboxSolAirdrop extends TransactionCommand `-------------------------------- @@ -41,7 +42,7 @@ export default class ToolboxSolBalance extends TransactionCommand null) @@ -47,7 +48,7 @@ export default class ToolboxSolUnwrap extends TransactionCommand null) @@ -56,7 +57,7 @@ export default class ToolboxSolWrap extends TransactionCommand = [ 'wallets', 'rpcs', 'explorer', + 'activeWallet', ] export const getDefaultConfigPath = (): string => { @@ -142,17 +154,62 @@ 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 + + if (isAssetSigner) { + // For asset-signer wallets, umi.identity is the real wallet (required for + // correct mpl-core CPI account layout). The asset-signer plugin stores + // PDA info on umi; use getEffectiveOwner(umi) for address derivation. + const walletPayerName = activeWallet.payer + if (!payerPath && walletPayerName && config.wallets) { + const payerWallet = config.wallets.find(w => w.name === walletPayerName) + if (payerWallet && payerWallet.type !== 'asset-signer' && 'path' in payerWallet) { + payerPath = payerWallet.path + } + } + + if (!payerPath) { + payerPath = config.keypair + } + + if (!payerPath && isTransactionContext) { + throw new Error( + `Asset-signer wallet '${activeWallet.name}' requires a payer wallet for gas fees.\n` + + `Set a default payer with: mplx config wallet add --asset --payer \n` + + `Or use the --payer flag.` + ) + } + + // Identity is the real wallet — commands build instructions normally. + // The asset-signer plugin on umi tells the send layer to wrap in execute(). + signer = await createSignerFromPath(payerPath) + assetSigner = { asset: activeWallet.asset, signerPda: activeWallet.address } + } 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 + const payer = payerPath ? await createSignerFromPath(payerPath) : signer const umi = createUmi(config.rpcUrl!, { commitment: config.commitment!, @@ -169,6 +226,10 @@ export const createContext = async (configPath: string, overrides: ConfigJson, i .use(genesis()) .use(dasApi()) + if (assetSigner) { + umi.use(assetSignerPlugin({ info: assetSigner, authority: signer })) + } + const storageProvider = await initStorageProvider(config) storageProvider && umi.use(storageProvider) diff --git a/src/lib/umi/assetSignerPlugin.ts b/src/lib/umi/assetSignerPlugin.ts new file mode 100644 index 0000000..2e84647 --- /dev/null +++ b/src/lib/umi/assetSignerPlugin.ts @@ -0,0 +1,43 @@ +import { publicKey, PublicKey, Signer, Umi } from '@metaplex-foundation/umi' +import { AssetSignerInfo } from '../Context.js' + +export type AssetSignerState = { + info: AssetSignerInfo + authority: Signer +} + +const ASSET_SIGNER_KEY = '__assetSigner' + +/** + * Umi plugin that activates asset-signer mode. Stores the asset-signer state + * on the umi instance so the send layer can wrap transactions in execute(). + * + * umi.identity remains the real wallet (required for correct mpl-core account + * resolution in CPI). Use getEffectiveOwner(umi) for address derivation. + */ +export const assetSignerPlugin = (state: AssetSignerState) => ({ + install(umi: Umi) { + ;(umi as any)[ASSET_SIGNER_KEY] = state + }, +}) + +/** + * Reads asset-signer state from a umi instance. + * Returns undefined when no asset-signer wallet is active. + */ +export const getAssetSigner = (umi: Umi): AssetSignerState | undefined => { + return (umi as any)[ASSET_SIGNER_KEY] +} + +/** + * Returns the effective owner for the current umi context: + * - Asset-signer active → the PDA pubkey + * - Normal mode → umi.identity.publicKey + * + * Use this for address derivation (ATA lookups, balance checks, default + * recipients) where the result should reflect the PDA, not the gas payer. + */ +export const getEffectiveOwner = (umi: Umi): PublicKey => { + const state = (umi as any)[ASSET_SIGNER_KEY] as AssetSignerState | undefined + return state ? publicKey(state.info.signerPda) : umi.identity.publicKey +} diff --git a/src/lib/umi/sendAndConfirm.ts b/src/lib/umi/sendAndConfirm.ts index 977645b..f56a45b 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) + } + // 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/wrapForAssetSigner.ts b/src/lib/umi/wrapForAssetSigner.ts new file mode 100644 index 0000000..2557ac4 --- /dev/null +++ b/src/lib/umi/wrapForAssetSigner.ts @@ -0,0 +1,55 @@ +import { execute, fetchAsset, fetchCollection } from '@metaplex-foundation/mpl-core' +import { Instruction, publicKey, Signer, TransactionBuilder, Umi } from '@metaplex-foundation/umi' +import { AssetSignerInfo } from '../Context.js' + +/** + * Rewrites signer accounts in an instruction: swaps the wallet pubkey for the + * PDA. Only isSigner accounts are rewritten so derived addresses (ATAs, PDAs) + * and payer accounts stay correct. + */ +function rewriteSignerAuthority(ix: Instruction, walletPubkey: string, pdaPubkey: string): Instruction { + const pda = publicKey(pdaPubkey) + return { + ...ix, + keys: ix.keys.map(k => + k.pubkey.toString() === walletPubkey && k.isSigner + ? { ...k, pubkey: pda } + : k + ), + } +} + +/** + * Wraps a TransactionBuilder's instructions inside an MPL Core `execute` call + * so the asset's signer PDA signs for them on-chain. + * + * umi.identity is the real wallet, so instructions have the wallet as + * authority. This function rewrites signer accounts (wallet → PDA) then + * wraps in execute() with the wallet as the caller. + */ +export const wrapForAssetSigner = async ( + umi: Umi, + transaction: TransactionBuilder, + assetSigner: AssetSignerInfo, + authority: 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 walletPubkey = authority.publicKey.toString() + const instructions = transaction.getInstructions().map(ix => + rewriteSignerAuthority(ix, walletPubkey, assetSigner.signerPda) + ) + + return execute(umi, { + asset, + collection, + instructions, + authority, + }) +} diff --git a/test/commands/core/core.execute.test.ts b/test/commands/core/core.execute.test.ts index d85f272..ab26946 100644 --- a/test/commands/core/core.execute.test.ts +++ b/test/commands/core/core.execute.test.ts @@ -1,7 +1,6 @@ import { expect } from 'chai' import { runCli } from '../../runCli' -import { createCoreAsset, createCoreCollection, extractAssetId, stripAnsi } from './corehelpers' -import { createAndFundToken } from './executehelpers' +import { createCoreAsset, createCoreCollection, stripAnsi } from './corehelpers' import { serializeInstruction } from '../../../src/lib/execute/deserializeInstruction.js' const ASSET_SIGNER_PDA_PATTERN = /Signer PDA:\s+([a-zA-Z0-9]+)/ @@ -19,12 +18,12 @@ describe('core asset execute commands', function () { await new Promise(resolve => setTimeout(resolve, 10000)) }) - describe('signer', () => { + 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', 'signer', assetId + 'core', 'asset', 'execute', 'info', assetId ]) const cleanStderr = stripAnsi(stderr) @@ -44,7 +43,7 @@ describe('core asset execute commands', function () { const { assetId } = await createCoreAsset(collectionId) const { stdout, stderr, code } = await runCli([ - 'core', 'asset', 'execute', 'signer', assetId + 'core', 'asset', 'execute', 'info', assetId ]) const output = stripAnsi(stdout) + stripAnsi(stderr) @@ -53,142 +52,13 @@ describe('core asset execute commands', function () { }) }) - describe('transfer-sol', () => { - it('transfers SOL from the asset signer PDA', async function () { - const { assetId } = await createCoreAsset() - - // Get the signer PDA - const { stdout: signerOut, stderr: signerErr } = await runCli([ - 'core', 'asset', 'execute', 'signer', assetId - ]) - const signerPda = extractSignerPda(stripAnsi(signerOut) + stripAnsi(signerErr)) - expect(signerPda).to.be.ok - - // Fund the signer PDA - await runCli(['toolbox', 'sol', 'transfer', '0.1', signerPda!]) - await new Promise(resolve => setTimeout(resolve, 5000)) - - // Transfer SOL from the signer PDA - const destination = 'TESTfCYwTPxME2cAnPcKvvF5xdPah3PY7naYQEP2kkx' - const { stdout, stderr, code } = await runCli([ - 'core', 'asset', 'execute', 'transfer-sol', assetId, - '--amount', '0.01', '--destination', destination - ]) - - const output = stripAnsi(stdout) + stripAnsi(stderr) - expect(code).to.equal(0) - expect(output).to.contain('SOL transferred from asset signer') - expect(output).to.contain('Signature:') - }) - - it('fails with an invalid amount', async function () { - const { assetId } = await createCoreAsset() - - try { - await runCli([ - 'core', 'asset', 'execute', 'transfer-sol', assetId, - '--amount', '-1', '--destination', 'TESTfCYwTPxME2cAnPcKvvF5xdPah3PY7naYQEP2kkx' - ]) - expect.fail('Expected command to fail with negative amount') - } catch (error: any) { - expect(error.message).to.contain('Amount must be a positive number') - } - }) - }) - - describe('transfer-token', () => { - it('transfers SPL tokens from the asset signer PDA', async function () { - const { assetId } = await createCoreAsset() - - // Get the signer PDA - const { stdout: signerOut, stderr: signerErr } = await runCli([ - 'core', 'asset', 'execute', 'signer', assetId - ]) - const signerPda = extractSignerPda(stripAnsi(signerOut) + stripAnsi(signerErr)) - expect(signerPda).to.be.ok - - // Create a fungible token and mint 1000 tokens to the asset signer PDA - const mintAddress = await createAndFundToken(signerPda!, 1000, 0) - await new Promise(resolve => setTimeout(resolve, 5000)) - - // Transfer 100 tokens from the signer PDA to destination - const destination = 'TESTfCYwTPxME2cAnPcKvvF5xdPah3PY7naYQEP2kkx' - const { stdout, stderr, code } = await runCli([ - 'core', 'asset', 'execute', 'transfer-token', assetId, - '--mint', mintAddress, '--amount', '100', '--destination', destination - ]) - - const output = stripAnsi(stdout) + stripAnsi(stderr) - expect(code).to.equal(0) - expect(output).to.contain('Tokens transferred from asset signer') - expect(output).to.contain('Signature:') - }) - }) - - describe('transfer-asset', () => { - it('transfers an asset owned by the signer PDA to a new owner', async function () { - // Create the "signing" asset (the one whose PDA will own another asset) - const { assetId: signingAssetId } = await createCoreAsset() - - // Get the signer PDA - const { stdout: signerOut, stderr: signerErr } = await runCli([ - 'core', 'asset', 'execute', 'signer', signingAssetId - ]) - const signerPda = extractSignerPda(stripAnsi(signerOut) + stripAnsi(signerErr)) - expect(signerPda).to.be.ok - - // Create a second asset owned by the signer PDA - const { stdout: createOut, stderr: createErr, code: createCode } = await runCli([ - 'core', 'asset', 'create', - '--name', 'Owned by PDA', - '--uri', 'https://example.com/pda-owned', - '--owner', signerPda!, - ], ['\n']) - expect(createCode).to.equal(0) - - const targetAssetId = extractAssetId(stripAnsi(createOut)) || extractAssetId(stripAnsi(createErr)) - expect(targetAssetId).to.be.ok - - // Transfer the target asset from the signer PDA to a new owner - const newOwner = 'TESTfCYwTPxME2cAnPcKvvF5xdPah3PY7naYQEP2kkx' - const { stdout, stderr, code } = await runCli([ - 'core', 'asset', 'execute', 'transfer-asset', signingAssetId, - '--asset', targetAssetId!, '--new-owner', newOwner - ]) - - const output = stripAnsi(stdout) + stripAnsi(stderr) - expect(code).to.equal(0) - expect(output).to.contain('Asset transferred from signer PDA') - expect(output).to.contain('Signature:') - - // Verify ownership changed - const { stdout: fetchOut } = await runCli(['core', 'asset', 'fetch', targetAssetId!]) - expect(stripAnsi(fetchOut)).to.contain(newOwner) - }) - - it('fails when target asset is not owned by the signer PDA', async function () { - const { assetId: signingAssetId } = await createCoreAsset() - const { assetId: otherAssetId } = await createCoreAsset() - - try { - await runCli([ - 'core', 'asset', 'execute', 'transfer-asset', signingAssetId, - '--asset', otherAssetId, '--new-owner', 'TESTfCYwTPxME2cAnPcKvvF5xdPah3PY7naYQEP2kkx' - ]) - expect.fail('Expected command to fail when target is not owned by PDA') - } catch (error: any) { - expect(error.message).to.contain('not owned by the asset signer PDA') - } - }) - }) - describe('raw', () => { it('executes a raw SOL transfer instruction via --instruction', async function () { const { assetId } = await createCoreAsset() // Get the signer PDA const { stdout: signerOut, stderr: signerErr } = await runCli([ - 'core', 'asset', 'execute', 'signer', assetId + 'core', 'asset', 'execute', 'info', assetId ]) const signerPda = extractSignerPda(stripAnsi(signerOut) + stripAnsi(signerErr)) expect(signerPda).to.be.ok From 12e2465abc102978b65a4043ef49fb007c79c91d Mon Sep 17 00:00:00 2001 From: Blockiosaurus Date: Wed, 18 Mar 2026 20:10:11 -0400 Subject: [PATCH 04/11] Tweaking asset signer methodology --- src/commands/core/asset/transfer.ts | 31 +++++++--------- src/commands/tm/transfer.ts | 7 ++-- src/commands/toolbox/sol/airdrop.ts | 3 +- src/commands/toolbox/sol/balance.ts | 3 +- src/commands/toolbox/sol/unwrap.ts | 5 ++- src/commands/toolbox/sol/wrap.ts | 5 ++- src/commands/toolbox/token/add-metadata.ts | 2 +- src/commands/toolbox/token/create.ts | 5 ++- src/commands/toolbox/token/mint.ts | 3 +- src/commands/toolbox/token/transfer.ts | 3 +- src/lib/Context.ts | 42 +++++++++++++++------- src/lib/umi/assetSignerPlugin.ts | 30 +++++----------- src/lib/umi/sendTransaction.ts | 9 +++++ src/lib/umi/wrapForAssetSigner.ts | 29 +++------------ 14 files changed, 79 insertions(+), 98 deletions(-) diff --git a/src/commands/core/asset/transfer.ts b/src/commands/core/asset/transfer.ts index 5e78f97..376eed3 100644 --- a/src/commands/core/asset/transfer.ts +++ b/src/commands/core/asset/transfer.ts @@ -5,7 +5,6 @@ import ora from 'ora' import { generateExplorerUrl } from '../../../explorers.js' import { TransactionCommand } from '../../../TransactionCommand.js' -import { getAssetSigner } from '../../../lib/umi/assetSignerPlugin.js' import umiSendAndConfirmTransaction from '../../../lib/umi/sendAndConfirm.js' import { txSignatureToString } from '../../../lib/util.js' @@ -40,23 +39,19 @@ export default class AssetTransfer extends TransactionCommand { @@ -67,7 +66,7 @@ export default class TmTransfer extends TransactionCommand { mint: asset.publicKey, token: findAssociatedTokenPda(umi, { mint: asset.publicKey, - owner: getEffectiveOwner(umi), + owner: umi.identity.publicKey, })[0], }) @@ -91,7 +90,7 @@ export default class TmTransfer extends TransactionCommand { transferIx = transferV1(umi, { mint: asset.publicKey, authority: umi.identity, - tokenOwner: getEffectiveOwner(umi), + tokenOwner: umi.identity.publicKey, destinationOwner, destinationToken: destinationToken[0], tokenStandard: TokenStandard.ProgrammableNonFungible, @@ -106,7 +105,7 @@ export default class TmTransfer extends TransactionCommand { transferIx = transferV1(umi, { mint: asset.publicKey, authority: umi.identity, - tokenOwner: getEffectiveOwner(umi), + tokenOwner: umi.identity.publicKey, destinationOwner, tokenStandard: unwrapOptionRecursively(asset.metadata.tokenStandard) || TokenStandard.NonFungible }) diff --git a/src/commands/toolbox/sol/airdrop.ts b/src/commands/toolbox/sol/airdrop.ts index 0b8a291..dcb6e78 100644 --- a/src/commands/toolbox/sol/airdrop.ts +++ b/src/commands/toolbox/sol/airdrop.ts @@ -3,7 +3,6 @@ import { Args } from '@oclif/core' import ora from 'ora' import { TransactionCommand } from '../../../TransactionCommand.js' import umiAirdrop from '../../../lib/toolbox/airdrop.js' -import { getEffectiveOwner } from '../../../lib/umi/assetSignerPlugin.js' @@ -50,7 +49,7 @@ export default class ToolboxSolAirdrop extends TransactionCommand `-------------------------------- @@ -42,7 +41,7 @@ export default class ToolboxSolBalance extends TransactionCommand null) @@ -48,7 +47,7 @@ export default class ToolboxSolUnwrap extends TransactionCommand null) @@ -57,7 +56,7 @@ export default class ToolboxSolWrap extends TransactionCommand w.name === walletPayerName) @@ -199,25 +201,41 @@ export const createContext = async (configPath: string, overrides: ConfigJson, i ) } - // Identity is the real wallet — commands build instructions normally. - // The asset-signer plugin on umi tells the send layer to wrap in execute(). - signer = await createSignerFromPath(payerPath) - assetSigner = { asset: activeWallet.asset, signerPda: activeWallet.address } + // The real wallet pays gas; context.signer stays as the real wallet + // for backward compat (genesis/distro commands). + realWalletSigner = await createSignerFromPath(payerPath) + signer = realWalletSigner + + // 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 = payerPath ? await createSignerFromPath(payerPath) : 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()) diff --git a/src/lib/umi/assetSignerPlugin.ts b/src/lib/umi/assetSignerPlugin.ts index 2e84647..3fbdb0d 100644 --- a/src/lib/umi/assetSignerPlugin.ts +++ b/src/lib/umi/assetSignerPlugin.ts @@ -1,4 +1,4 @@ -import { publicKey, PublicKey, Signer, Umi } from '@metaplex-foundation/umi' +import { Signer, Umi } from '@metaplex-foundation/umi' import { AssetSignerInfo } from '../Context.js' export type AssetSignerState = { @@ -6,38 +6,26 @@ export type AssetSignerState = { authority: Signer } -const ASSET_SIGNER_KEY = '__assetSigner' +const assetSignerStore = new WeakMap() /** * Umi plugin that activates asset-signer mode. Stores the asset-signer state - * on the umi instance so the send layer can wrap transactions in execute(). + * keyed by the umi instance so the send layer can wrap transactions in execute(). * - * umi.identity remains the real wallet (required for correct mpl-core account - * resolution in CPI). Use getEffectiveOwner(umi) for address derivation. + * Both umi.identity and umi.payer are noopSigner(PDA), so instructions are + * built with the PDA for all accounts. The send layer overrides the + * transaction fee payer to the real wallet (authority) via setFeePayer(). */ export const assetSignerPlugin = (state: AssetSignerState) => ({ install(umi: Umi) { - ;(umi as any)[ASSET_SIGNER_KEY] = state + assetSignerStore.set(umi, state) }, }) /** - * Reads asset-signer state from a umi instance. + * 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 (umi as any)[ASSET_SIGNER_KEY] -} - -/** - * Returns the effective owner for the current umi context: - * - Asset-signer active → the PDA pubkey - * - Normal mode → umi.identity.publicKey - * - * Use this for address derivation (ATA lookups, balance checks, default - * recipients) where the result should reflect the PDA, not the gas payer. - */ -export const getEffectiveOwner = (umi: Umi): PublicKey => { - const state = (umi as any)[ASSET_SIGNER_KEY] as AssetSignerState | undefined - return state ? publicKey(state.info.signerPda) : umi.identity.publicKey + return assetSignerStore.get(umi) } diff --git a/src/lib/umi/sendTransaction.ts b/src/lib/umi/sendTransaction.ts index 3384f53..f57736d 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 gas and provides a valid signature. + const assetSigner = getAssetSigner(umi) + if (assetSigner) { + transaction = transaction.setFeePayer(assetSigner.authority) + } + 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 index 2557ac4..00e9421 100644 --- a/src/lib/umi/wrapForAssetSigner.ts +++ b/src/lib/umi/wrapForAssetSigner.ts @@ -1,31 +1,13 @@ import { execute, fetchAsset, fetchCollection } from '@metaplex-foundation/mpl-core' -import { Instruction, publicKey, Signer, TransactionBuilder, Umi } from '@metaplex-foundation/umi' +import { publicKey, Signer, TransactionBuilder, Umi } from '@metaplex-foundation/umi' import { AssetSignerInfo } from '../Context.js' -/** - * Rewrites signer accounts in an instruction: swaps the wallet pubkey for the - * PDA. Only isSigner accounts are rewritten so derived addresses (ATAs, PDAs) - * and payer accounts stay correct. - */ -function rewriteSignerAuthority(ix: Instruction, walletPubkey: string, pdaPubkey: string): Instruction { - const pda = publicKey(pdaPubkey) - return { - ...ix, - keys: ix.keys.map(k => - k.pubkey.toString() === walletPubkey && k.isSigner - ? { ...k, pubkey: pda } - : k - ), - } -} - /** * Wraps a TransactionBuilder's instructions inside an MPL Core `execute` call * so the asset's signer PDA signs for them on-chain. * - * umi.identity is the real wallet, so instructions have the wallet as - * authority. This function rewrites signer accounts (wallet → PDA) then - * wraps in execute() with the wallet as the caller. + * Since umi.identity is a noopSigner keyed to the PDA, instructions are + * already built with the PDA as authority — no rewriting needed. */ export const wrapForAssetSigner = async ( umi: Umi, @@ -41,10 +23,7 @@ export const wrapForAssetSigner = async ( collection = await fetchCollection(umi, asset.updateAuthority.address) } - const walletPubkey = authority.publicKey.toString() - const instructions = transaction.getInstructions().map(ix => - rewriteSignerAuthority(ix, walletPubkey, assetSigner.signerPda) - ) + const instructions = transaction.getInstructions() return execute(umi, { asset, From ecf68b7105c78805326c366eb6aa17da515933ef Mon Sep 17 00:00:00 2001 From: MarkSackerberg <93528482+MarkSackerberg@users.noreply.github.com> Date: Thu, 19 Mar 2026 10:38:29 +0100 Subject: [PATCH 05/11] allow -p --- src/commands/config/wallets/add.ts | 2 +- src/commands/config/wallets/list.ts | 6 +-- src/lib/Context.ts | 39 +++++++++------- src/lib/umi/assetSignerPlugin.ts | 7 +-- src/lib/umi/sendAndConfirm.ts | 2 +- src/lib/umi/sendTransaction.ts | 4 +- src/lib/umi/wrapForAssetSigner.ts | 5 ++ test/commands/core/executehelpers.ts | 69 ---------------------------- test/runCli.ts | 6 ++- 9 files changed, 41 insertions(+), 99 deletions(-) delete mode 100644 test/commands/core/executehelpers.ts diff --git a/src/commands/config/wallets/add.ts b/src/commands/config/wallets/add.ts index 81a2f47..91c8287 100644 --- a/src/commands/config/wallets/add.ts +++ b/src/commands/config/wallets/add.ts @@ -25,7 +25,7 @@ export default class ConfigWalletAddCommand extends Command { description: 'Asset ID to create an asset-signer wallet from', }), payer: Flags.string({ - description: 'Default payer wallet name for gas fees (for asset-signer wallets)', + description: 'Default fee payer wallet name (for asset-signer wallets)', }), } diff --git a/src/commands/config/wallets/list.ts b/src/commands/config/wallets/list.ts index b129113..e286b44 100644 --- a/src/commands/config/wallets/list.ts +++ b/src/commands/config/wallets/list.ts @@ -37,11 +37,7 @@ export default class ConfigWalletListCommand extends Command { }) this.log('Installed Wallets:') - for (const wallet of wallets) { - const marker = wallet.active ? ' (active)' : '' - const typeLabel = wallet.type === 'asset-signer' ? ' [asset-signer]' : '' - this.log(` ${wallet.name}: ${wallet.address}${typeLabel}${marker}`) - } + console.table(wallets) return { wallets } } diff --git a/src/lib/Context.ts b/src/lib/Context.ts index 4ec4aed..c0b2105 100644 --- a/src/lib/Context.ts +++ b/src/lib/Context.ts @@ -180,30 +180,33 @@ export const createContext = async (configPath: string, overrides: ConfigJson, i 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 overrides the transaction fee payer to the real wallet. + // 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 (!payerPath && walletPayerName && config.wallets) { - const payerWallet = config.wallets.find(w => w.name === walletPayerName) - if (payerWallet && payerWallet.type !== 'asset-signer' && 'path' in payerWallet) { - payerPath = payerWallet.path + 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 (!payerPath) { - payerPath = config.keypair + if (!ownerPath) { + ownerPath = config.keypair } - if (!payerPath && isTransactionContext) { + if (!ownerPath && isTransactionContext) { throw new Error( - `Asset-signer wallet '${activeWallet.name}' requires a payer wallet for gas fees.\n` + - `Set a default payer with: mplx config wallet add --asset --payer \n` + - `Or use the --payer flag.` + `Asset-signer wallet '${activeWallet.name}' requires an owner wallet.\n` + + `Set the owner with: mplx config wallet add --asset --payer \n` + + `Or set a default keypair in your config.` ) } - // The real wallet pays gas; context.signer stays as the real wallet - // for backward compat (genesis/distro commands). - realWalletSigner = await createSignerFromPath(payerPath) + realWalletSigner = await createSignerFromPath(ownerPath) signer = realWalletSigner // Identity is a noop signer keyed to the PDA — instructions naturally @@ -244,8 +247,12 @@ export const createContext = async (configPath: string, overrides: ConfigJson, i .use(genesis()) .use(dasApi()) - if (assetSigner) { - umi.use(assetSignerPlugin({ info: assetSigner, authority: signer })) + 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) diff --git a/src/lib/umi/assetSignerPlugin.ts b/src/lib/umi/assetSignerPlugin.ts index 3fbdb0d..636f1f1 100644 --- a/src/lib/umi/assetSignerPlugin.ts +++ b/src/lib/umi/assetSignerPlugin.ts @@ -3,7 +3,8 @@ import { AssetSignerInfo } from '../Context.js' export type AssetSignerState = { info: AssetSignerInfo - authority: Signer + authority: Signer // Asset owner — signs the execute instruction + payer: Signer // Fee payer — can differ from authority via -p flag } const assetSignerStore = new WeakMap() @@ -13,8 +14,8 @@ const assetSignerStore = new WeakMap() * 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 overrides the - * transaction fee payer to the real wallet (authority) via setFeePayer(). + * 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) { diff --git a/src/lib/umi/sendAndConfirm.ts b/src/lib/umi/sendAndConfirm.ts index f56a45b..ae081a1 100644 --- a/src/lib/umi/sendAndConfirm.ts +++ b/src/lib/umi/sendAndConfirm.ts @@ -18,7 +18,7 @@ const umiSendAndConfirmTransaction = async ( let tx = transaction const assetSigner = getAssetSigner(umi) if (assetSigner) { - tx = await wrapForAssetSigner(umi, transaction, assetSigner.info, assetSigner.authority) + tx = await wrapForAssetSigner(umi, transaction, assetSigner.info, assetSigner.authority, assetSigner.payer) } // Send transaction diff --git a/src/lib/umi/sendTransaction.ts b/src/lib/umi/sendTransaction.ts index f57736d..6ebb047 100644 --- a/src/lib/umi/sendTransaction.ts +++ b/src/lib/umi/sendTransaction.ts @@ -40,10 +40,10 @@ 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 gas and provides a valid signature. + // wallet so it pays fees and provides a valid signature. const assetSigner = getAssetSigner(umi) if (assetSigner) { - transaction = transaction.setFeePayer(assetSigner.authority) + transaction = transaction.setFeePayer(assetSigner.payer) } let signedTx = await transaction.buildAndSign(umi).catch((error) => { diff --git a/src/lib/umi/wrapForAssetSigner.ts b/src/lib/umi/wrapForAssetSigner.ts index 00e9421..36fb7fe 100644 --- a/src/lib/umi/wrapForAssetSigner.ts +++ b/src/lib/umi/wrapForAssetSigner.ts @@ -8,12 +8,16 @@ import { AssetSignerInfo } from '../Context.js' * * Since umi.identity is a noopSigner keyed to the PDA, instructions are * already built with the PDA as authority — no rewriting needed. + * + * @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) @@ -30,5 +34,6 @@ export const wrapForAssetSigner = async ( collection, instructions, authority, + payer, }) } diff --git a/test/commands/core/executehelpers.ts b/test/commands/core/executehelpers.ts deleted file mode 100644 index b5de6d8..0000000 --- a/test/commands/core/executehelpers.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { createUmi } from '@metaplex-foundation/umi-bundle-defaults' -import { createFungible, mplTokenMetadata } from '@metaplex-foundation/mpl-token-metadata' -import { mplCore } from '@metaplex-foundation/mpl-core' -import { - createAssociatedToken, - findAssociatedTokenPda, - mintTokensTo, - mplToolbox, -} from '@metaplex-foundation/mpl-toolbox' -import { - generateSigner, - keypairIdentity, - percentAmount, - publicKey, -} from '@metaplex-foundation/umi' -import fs from 'node:fs' -import path from 'node:path' - -const TEST_RPC = 'http://127.0.0.1:8899' -const KEYPAIR_PATH = path.join(process.cwd(), 'test-files', 'key.json') - -/** - * Creates a umi instance configured with the test keypair and local validator. - */ -export function createTestUmi() { - const umi = createUmi(TEST_RPC) - .use(mplCore()) - .use(mplToolbox()) - .use(mplTokenMetadata()) - const keypairData = JSON.parse(fs.readFileSync(KEYPAIR_PATH, 'utf-8')) - const keypair = umi.eddsa.createKeypairFromSecretKey(new Uint8Array(keypairData)) - umi.use(keypairIdentity(keypair)) - return umi -} - -/** - * Creates a fungible SPL token and mints tokens to a specified owner. - * Returns the mint address. - */ -export async function createAndFundToken( - owner: string, - amount: number, - decimals: number = 0, -): Promise { - const umi = createTestUmi() - const mint = generateSigner(umi) - const ownerPubkey = publicKey(owner) - - const tx = createFungible(umi, { - mint, - name: 'Test Token', - symbol: 'TST', - uri: 'https://example.com/test-token.json', - decimals, - sellerFeeBasisPoints: percentAmount(0), - }) - .add(createAssociatedToken(umi, { - mint: mint.publicKey, - owner: ownerPubkey, - })) - .add(mintTokensTo(umi, { - mint: mint.publicKey, - token: findAssociatedTokenPda(umi, { mint: mint.publicKey, owner: ownerPubkey }), - amount, - })) - - await tx.sendAndConfirm(umi) - return mint.publicKey.toString() -} 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'] }) From a7ac8eac865408fd3eb0c9547a5e69ea81870c00 Mon Sep 17 00:00:00 2001 From: MarkSackerberg <93528482+MarkSackerberg@users.noreply.github.com> Date: Thu, 19 Mar 2026 10:38:37 +0100 Subject: [PATCH 06/11] add tests --- test/commands/core/core.asset-signer.test.ts | 196 +++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 test/commands/core/core.asset-signer.test.ts 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..0099a16 --- /dev/null +++ b/test/commands/core/core.asset-signer.test.ts @@ -0,0 +1,196 @@ +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) + 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 PDA address, not the wallet address + expect(output).to.contain(signerPda.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') + }) + }) +}) From 275495b095be520f7bd01799d6bdb734a3b2f198 Mon Sep 17 00:00:00 2001 From: MarkSackerberg <93528482+MarkSackerberg@users.noreply.github.com> Date: Thu, 19 Mar 2026 11:15:53 +0100 Subject: [PATCH 07/11] use umi identity instead of signer --- src/commands/bg/collection/create.ts | 6 +++--- src/commands/bg/nft/burn.ts | 2 +- src/commands/bg/nft/create.ts | 4 ++-- src/commands/bg/nft/transfer.ts | 2 +- src/commands/distro/create.ts | 2 +- src/commands/distro/deposit.ts | 6 +++--- src/commands/distro/withdraw.ts | 6 +++--- src/commands/genesis/bucket/add-launch-pool.ts | 6 +++--- src/commands/genesis/bucket/add-presale.ts | 2 +- src/commands/genesis/bucket/add-unlocked.ts | 2 +- src/commands/genesis/claim-unlocked.ts | 2 +- src/commands/genesis/claim.ts | 2 +- src/commands/genesis/create.ts | 2 +- src/commands/genesis/deposit.ts | 6 +++--- src/commands/genesis/finalize.ts | 2 +- src/commands/genesis/launch/create.ts | 2 +- src/commands/genesis/launch/register.ts | 2 +- src/commands/genesis/presale/claim.ts | 2 +- src/commands/genesis/presale/deposit.ts | 4 ++-- src/commands/genesis/revoke.ts | 2 +- src/commands/genesis/withdraw.ts | 6 +++--- 21 files changed, 35 insertions(+), 35 deletions(-) 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/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, }) From 7d9e15ecac8fd8c3cf22bff0b51b2a389f84ca0d Mon Sep 17 00:00:00 2001 From: MarkSackerberg <93528482+MarkSackerberg@users.noreply.github.com> Date: Thu, 19 Mar 2026 11:37:54 +0100 Subject: [PATCH 08/11] fix inner signers (for core) --- src/lib/umi/wrapForAssetSigner.ts | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/src/lib/umi/wrapForAssetSigner.ts b/src/lib/umi/wrapForAssetSigner.ts index 36fb7fe..a6d83a7 100644 --- a/src/lib/umi/wrapForAssetSigner.ts +++ b/src/lib/umi/wrapForAssetSigner.ts @@ -9,6 +9,9 @@ import { AssetSignerInfo } from '../Context.js' * 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) */ @@ -29,11 +32,36 @@ export const wrapForAssetSigner = async ( const instructions = transaction.getInstructions() - return execute(umi, { + // 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, + ]) } From 914bec2503113da43de772c92f071054a4d3680c Mon Sep 17 00:00:00 2001 From: MarkSackerberg <93528482+MarkSackerberg@users.noreply.github.com> Date: Thu, 19 Mar 2026 12:26:20 +0100 Subject: [PATCH 09/11] move raw command to toolbox --- src/commands/core/asset/execute/index.ts | 1 - .../{core/asset/execute => toolbox}/raw.ts | 80 ++++++++----------- test/commands/core/core.execute.test.ts | 68 ---------------- test/commands/toolbox/toolbox.raw.test.ts | 61 ++++++++++++++ 4 files changed, 95 insertions(+), 115 deletions(-) rename src/commands/{core/asset/execute => toolbox}/raw.ts (51%) create mode 100644 test/commands/toolbox/toolbox.raw.test.ts diff --git a/src/commands/core/asset/execute/index.ts b/src/commands/core/asset/execute/index.ts index b270741..6dcb7fc 100644 --- a/src/commands/core/asset/execute/index.ts +++ b/src/commands/core/asset/execute/index.ts @@ -5,7 +5,6 @@ export default class CoreAssetExecute extends Command { static override examples = [ '<%= config.bin %> core asset execute info ', - '<%= config.bin %> core asset execute raw --instruction ', ] public async run(): Promise { diff --git a/src/commands/core/asset/execute/raw.ts b/src/commands/toolbox/raw.ts similarity index 51% rename from src/commands/core/asset/execute/raw.ts rename to src/commands/toolbox/raw.ts index 9ee5bea..6d721b6 100644 --- a/src/commands/core/asset/execute/raw.ts +++ b/src/commands/toolbox/raw.ts @@ -1,32 +1,28 @@ -import { execute, fetchAsset, fetchCollection, findAssetSignerPda } from '@metaplex-foundation/mpl-core' -import { publicKey } from '@metaplex-foundation/umi' -import { Args, Flags } from '@oclif/core' +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 { txSignatureToString } from '../../../../lib/util.js' -import { deserializeInstruction } from '../../../../lib/execute/deserializeInstruction.js' +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 ExecuteRaw extends TransactionCommand { - static override description = `Execute arbitrary instructions signed by an asset's signer PDA. +export default class ToolboxRaw extends TransactionCommand { + static override description = `Execute arbitrary base64-encoded Solana instructions. -Instructions must be base64-encoded serialized Solana instructions. -Each instruction should be constructed with the asset's signer PDA as the signer. +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', + '<%= config.bin %> <%= command.id %> --instruction ', + '<%= config.bin %> <%= command.id %> --instruction --instruction ', + 'echo "" | <%= config.bin %> <%= command.id %> --stdin', ] - static override args = { - assetId: Args.string({ description: 'Asset whose signer PDA will sign the instructions', required: true }), - } - static override flags = { instruction: Flags.string({ char: 'i', @@ -53,7 +49,7 @@ Alternatively, pipe instructions via stdin with --stdin.` } public async run(): Promise { - const { args, flags } = await this.parse(ExecuteRaw) + const { flags } = await this.parse(ToolboxRaw) const { umi, explorer, chain } = this.context let instructionData: string[] @@ -70,21 +66,9 @@ Alternatively, pipe instructions via stdin with --stdin.` this.error('No instructions provided') } - const spinner = ora('Fetching asset...').start() + const spinner = ora('Deserializing instructions...').start() try { - const assetPubkey = publicKey(args.assetId) - const asset = await fetchAsset(umi, assetPubkey) - - let collection - if (asset.updateAuthority.type === 'Collection' && asset.updateAuthority.address) { - collection = await fetchCollection(umi, asset.updateAuthority.address) - } - - const [assetSignerPda] = findAssetSignerPda(umi, { asset: assetPubkey }) - - spinner.text = 'Deserializing instructions...' - const instructions = instructionData.map((b64, idx) => { try { return deserializeInstruction(b64) @@ -96,32 +80,36 @@ Alternatively, pipe instructions via stdin with --stdin.` spinner.text = `Executing ${instructions.length} instruction(s)...` - const result = await execute(umi, { - asset, - collection, - instructions, - }).sendAndConfirm(umi) + 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 signature = txSignatureToString(result.signature) - const explorerUrl = generateExplorerUrl(explorer, chain, signature, 'transaction') + const sig = typeof signature === 'string' ? signature : txSignatureToString(signature as Uint8Array) + const explorerUrl = generateExplorerUrl(explorer, chain, sig, 'transaction') - spinner.succeed(`Executed ${instructions.length} instruction(s) via asset signer`) + spinner.succeed(`Executed ${instructions.length} instruction(s)`) this.logSuccess( `-------------------------------- - Asset: ${args.assetId} - Signer PDA: ${assetSignerPda.toString()} + Signer: ${umi.identity.publicKey.toString()} Instructions: ${instructions.length} - Signature: ${signature} + Signature: ${sig} --------------------------------` ) this.log(explorerUrl) return { - asset: args.assetId, - signerPda: assetSignerPda.toString(), + signer: umi.identity.publicKey.toString(), instructionCount: instructions.length, - signature, + signature: sig, explorer: explorerUrl, } } catch (error) { diff --git a/test/commands/core/core.execute.test.ts b/test/commands/core/core.execute.test.ts index ab26946..810da72 100644 --- a/test/commands/core/core.execute.test.ts +++ b/test/commands/core/core.execute.test.ts @@ -1,7 +1,6 @@ import { expect } from 'chai' import { runCli } from '../../runCli' import { createCoreAsset, createCoreCollection, stripAnsi } from './corehelpers' -import { serializeInstruction } from '../../../src/lib/execute/deserializeInstruction.js' const ASSET_SIGNER_PDA_PATTERN = /Signer PDA:\s+([a-zA-Z0-9]+)/ @@ -13,11 +12,6 @@ const extractSignerPda = (str: string) => { describe('core asset execute commands', function () { this.timeout(120000) - before(async () => { - await runCli(['toolbox', 'sol', 'airdrop', '100', 'TESTfCYwTPxME2cAnPcKvvF5xdPah3PY7naYQEP2kkx']) - await new Promise(resolve => setTimeout(resolve, 10000)) - }) - describe('info', () => { it('shows the asset signer PDA address and balance', async function () { const { assetId } = await createCoreAsset() @@ -51,66 +45,4 @@ describe('core asset execute commands', function () { expect(output).to.contain('Signer PDA:') }) }) - - describe('raw', () => { - it('executes a raw SOL transfer instruction via --instruction', async function () { - const { assetId } = await createCoreAsset() - - // Get the signer PDA - const { stdout: signerOut, stderr: signerErr } = await runCli([ - 'core', 'asset', 'execute', 'info', assetId - ]) - const signerPda = extractSignerPda(stripAnsi(signerOut) + stripAnsi(signerErr)) - expect(signerPda).to.be.ok - - // Fund the signer PDA - await runCli(['toolbox', 'sol', 'transfer', '0.1', signerPda!]) - await new Promise(resolve => setTimeout(resolve, 5000)) - - // Build a system program Transfer instruction manually: - // System program transfer = discriminator 2 (u32 LE) + lamports (u64 LE) - const SYSTEM_PROGRAM = '11111111111111111111111111111111' - const destination = 'TESTfCYwTPxME2cAnPcKvvF5xdPah3PY7naYQEP2kkx' - const lamports = 10000n // 0.00001 SOL - - const data = new Uint8Array(12) - const view = new DataView(data.buffer) - view.setUint32(0, 2, true) // Transfer instruction discriminator - view.setBigUint64(4, lamports, true) // Amount in lamports - - const instruction = { - programId: SYSTEM_PROGRAM, - keys: [ - { pubkey: signerPda!, isSigner: true, isWritable: true }, - { pubkey: destination, isSigner: false, isWritable: true }, - ], - data, - } - - const serialized = serializeInstruction(instruction as any) - - const { stdout, stderr, code } = await runCli([ - 'core', 'asset', 'execute', 'raw', assetId, - '--instruction', serialized - ]) - - const output = stripAnsi(stdout) + stripAnsi(stderr) - expect(code).to.equal(0) - expect(output).to.contain('Executed 1 instruction(s) via asset signer') - expect(output).to.contain('Signature:') - }) - - it('fails when no instructions are provided', async function () { - const { assetId } = await createCoreAsset() - - try { - await runCli([ - 'core', 'asset', 'execute', 'raw', assetId - ]) - 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/commands/toolbox/toolbox.raw.test.ts b/test/commands/toolbox/toolbox.raw.test.ts new file mode 100644 index 0000000..db14fb4 --- /dev/null +++ b/test/commands/toolbox/toolbox.raw.test.ts @@ -0,0 +1,61 @@ +import { expect } from 'chai' +import { runCli, KEYPAIR_PATH } from '../../runCli' +import { serializeInstruction } from '../../../src/lib/execute/deserializeInstruction.js' +import { createUmi } from '@metaplex-foundation/umi-bundle-defaults' +import { keypairIdentity, publicKey } from '@metaplex-foundation/umi' +import fs from 'node:fs' + +const stripAnsi = (str: string) => str.replace(/\u001b\[\d+m/g, '') + +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') + } + }) +}) From 86b19fa70eb7216bc180175df50f022a31b01b5d Mon Sep 17 00:00:00 2001 From: MarkSackerberg <93528482+MarkSackerberg@users.noreply.github.com> Date: Thu, 19 Mar 2026 12:26:37 +0100 Subject: [PATCH 10/11] Remove redundant --payer on wallet add --- src/commands/config/wallets/add.ts | 59 +++++++++++++++++------------- src/lib/Context.ts | 5 +-- 2 files changed, 35 insertions(+), 29 deletions(-) diff --git a/src/commands/config/wallets/add.ts b/src/commands/config/wallets/add.ts index 91c8287..0dd2ef2 100644 --- a/src/commands/config/wallets/add.ts +++ b/src/commands/config/wallets/add.ts @@ -1,9 +1,11 @@ import { Args, Command, Flags } from '@oclif/core' import fs from 'fs' import { dirname } from 'path' -import { findAssetSignerPda } from '@metaplex-foundation/mpl-core' +import { fetchAsset, findAssetSignerPda } from '@metaplex-foundation/mpl-core' import { publicKey } from '@metaplex-foundation/umi' -import { createSignerFromPath, getDefaultConfigPath, readConfig, WalletEntry } from '../../../lib/Context.js' +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, DUMMY_UMI } from '../../../lib/util.js' @@ -24,17 +26,12 @@ export default class ConfigWalletAddCommand extends Command { asset: Flags.string({ description: 'Asset ID to create an asset-signer wallet from', }), - payer: Flags.string({ - description: 'Default fee payer wallet name (for asset-signer wallets)', - }), } 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 ', - '<%= config.bin %> <%= command.id %> vault --asset --payer my-wallet', ] public async run(): Promise { @@ -45,8 +42,8 @@ export default class ConfigWalletAddCommand extends Command { this.error(`Invalid wallet name '${args.name}'. Name must contain only letters, numbers, hyphens (-), and underscores (_). Example: 'my-wallet' or 'dev_wallet_1'`) } - const path = flags.config ?? getDefaultConfigPath() - const config = readConfig(path) + const configPath = flags.config ?? getDefaultConfigPath() + const config = readConfig(configPath) if (!config.wallets) { config.wallets = [] @@ -65,15 +62,25 @@ export default class ConfigWalletAddCommand extends Command { const assetPubkey = publicKey(flags.asset) const [pdaPubkey] = findAssetSignerPda(DUMMY_UMI, { asset: assetPubkey }) - // Validate payer reference if provided - if (flags.payer) { - const payerWallet = config.wallets.find(w => w.name === flags.payer) - if (!payerWallet) { - this.error(`Payer wallet '${flags.payer}' not found. Add it first with 'mplx config wallet add'.`) - } - if (payerWallet.type === 'asset-signer') { - this.error(`Payer wallet '${flags.payer}' is an asset-signer wallet. The payer must be a file or ledger wallet.`) - } + // 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.`) + }) + + const ownerAddress = asset.owner.toString() + + // 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((w) => w.address === pdaPubkey.toString()) @@ -86,21 +93,21 @@ export default class ConfigWalletAddCommand extends Command { type: 'asset-signer', asset: flags.asset, address: pdaPubkey.toString(), - ...(flags.payer ? { payer: flags.payer } : {}), + payer: ownerWallet.name, } config.wallets.push(wallet) - const dir = dirname(path) + const dir = dirname(configPath) ensureDirectoryExists(dir) - writeJsonSync(path, config) + writeJsonSync(configPath, config) this.log( `✅ Asset-signer wallet '${args.name}' added!\n` + ` Asset: ${flags.asset}\n` + ` Signer PDA: ${pdaPubkey.toString()}\n` + - (flags.payer ? ` Payer: ${flags.payer}\n` : '') + - `\nUse 'mplx config wallet set ${args.name}' to make this your active wallet.` + ` Owner: ${ownerWallet.name} (${shortenAddress(ownerAddress)})\n` + + `\nUse 'mplx config wallets set ${args.name}' to make this your active wallet.` ) return { @@ -108,7 +115,7 @@ export default class ConfigWalletAddCommand extends Command { type: 'asset-signer', asset: flags.asset, address: pdaPubkey.toString(), - payer: flags.payer, + owner: ownerWallet.name, } } @@ -145,9 +152,9 @@ export default class ConfigWalletAddCommand extends Command { 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/lib/Context.ts b/src/lib/Context.ts index c0b2105..482a1e3 100644 --- a/src/lib/Context.ts +++ b/src/lib/Context.ts @@ -200,9 +200,8 @@ export const createContext = async (configPath: string, overrides: ConfigJson, i if (!ownerPath && isTransactionContext) { throw new Error( - `Asset-signer wallet '${activeWallet.name}' requires an owner wallet.\n` + - `Set the owner with: mplx config wallet add --asset --payer \n` + - `Or set a default keypair in your config.` + `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.` ) } From c0355847998c53ca01c11d0697680b709ce32fa0 Mon Sep 17 00:00:00 2001 From: MarkSackerberg <93528482+MarkSackerberg@users.noreply.github.com> Date: Thu, 19 Mar 2026 13:46:55 +0100 Subject: [PATCH 11/11] code rabbit --- src/commands/config/wallets/set.ts | 3 +++ src/commands/core/asset/execute/index.ts | 2 +- src/commands/toolbox/raw.ts | 1 + src/lib/Context.ts | 8 ++++++-- test/commands/core/core.asset-signer.test.ts | 4 +++- test/commands/toolbox/toolbox.raw.test.ts | 3 +-- 6 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/commands/config/wallets/set.ts b/src/commands/config/wallets/set.ts index 42cb20a..2bee021 100644 --- a/src/commands/config/wallets/set.ts +++ b/src/commands/config/wallets/set.ts @@ -67,6 +67,9 @@ export default class ConfigWalletSetCommand extends Command { 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 } diff --git a/src/commands/core/asset/execute/index.ts b/src/commands/core/asset/execute/index.ts index 6dcb7fc..15a8129 100644 --- a/src/commands/core/asset/execute/index.ts +++ b/src/commands/core/asset/execute/index.ts @@ -8,6 +8,6 @@ export default class CoreAssetExecute extends Command { ] public async run(): Promise { - const {args, flags} = await this.parse(CoreAssetExecute) + await this.parse(CoreAssetExecute) } } diff --git a/src/commands/toolbox/raw.ts b/src/commands/toolbox/raw.ts index 6d721b6..06270d0 100644 --- a/src/commands/toolbox/raw.ts +++ b/src/commands/toolbox/raw.ts @@ -80,6 +80,7 @@ Alternatively, pipe instructions via stdin with --stdin.` 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(), diff --git a/src/lib/Context.ts b/src/lib/Context.ts index 482a1e3..14572a3 100644 --- a/src/lib/Context.ts +++ b/src/lib/Context.ts @@ -205,8 +205,12 @@ export const createContext = async (configPath: string, overrides: ConfigJson, i ) } - realWalletSigner = await createSignerFromPath(ownerPath) - signer = realWalletSigner + 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(). diff --git a/test/commands/core/core.asset-signer.test.ts b/test/commands/core/core.asset-signer.test.ts index 0099a16..a97f2b0 100644 --- a/test/commands/core/core.asset-signer.test.ts +++ b/test/commands/core/core.asset-signer.test.ts @@ -102,6 +102,7 @@ describe('asset-signer wallet', function () { 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 @@ -124,8 +125,9 @@ describe('asset-signer wallet', function () { const output = stripAnsi(stdout) + stripAnsi(stderr) expect(code).to.equal(0) - // Should show the PDA address, not the wallet address + // 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 () { diff --git a/test/commands/toolbox/toolbox.raw.test.ts b/test/commands/toolbox/toolbox.raw.test.ts index db14fb4..fa1f057 100644 --- a/test/commands/toolbox/toolbox.raw.test.ts +++ b/test/commands/toolbox/toolbox.raw.test.ts @@ -1,12 +1,11 @@ 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' -const stripAnsi = (str: string) => str.replace(/\u001b\[\d+m/g, '') - describe('toolbox raw command', function () { this.timeout(120000)