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 1/2] 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 2/2] 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