Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions src/commands/core/asset/execute/index.ts
Original file line number Diff line number Diff line change
@@ -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 <assetId>',
'<%= config.bin %> core asset execute transfer-sol <assetId> --amount 0.5 --destination <address>',
'<%= config.bin %> core asset execute transfer-token <assetId> --mint <mint> --amount 1000 --destination <address>',
'<%= config.bin %> core asset execute transfer-asset <assetId> --asset <targetAssetId> --new-owner <address>',
'<%= config.bin %> core asset execute raw <assetId> --instruction <base64>',
]

public async run(): Promise<void> {
const {args, flags} = await this.parse(CoreAssetExecute)
}
Comment on lines +14 to +16
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Unused parsed variables in parent command.

The args and flags variables are parsed but never used. For a parent command that serves as an entry point to subcommands, the run() method body can be empty or simply display help.

♻️ Suggested simplification
  public async run(): Promise<void> {
-   const {args, flags} = await this.parse(CoreAssetExecute)
+   await this.parse(CoreAssetExecute)
+   // Parent command - subcommands handle actual execution
  }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public async run(): Promise<void> {
const {args, flags} = await this.parse(CoreAssetExecute)
}
public async run(): Promise<void> {
await this.parse(CoreAssetExecute)
// Parent command - subcommands handle actual execution
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/commands/core/asset/execute/index.ts` around lines 14 - 16, The run()
method of CoreAssetExecute currently destructures unused vars (args, flags) from
await this.parse(CoreAssetExecute); either remove the unused destructuring
entirely and leave run() empty (for a parent/subcommand entry), or explicitly
show help by calling the command help helper (e.g., await
this.parse(CoreAssetExecute) without destructuring and then call this._help() or
this.help()) so args and flags are not unused; update the run() implementation
in CoreAssetExecute accordingly.

}
132 changes: 132 additions & 0 deletions src/commands/core/asset/execute/raw.ts
Original file line number Diff line number Diff line change
@@ -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<typeof ExecuteRaw> {
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 %> <assetId> --instruction <base64EncodedInstruction>',
'<%= config.bin %> <%= command.id %> <assetId> --instruction <ix1> --instruction <ix2>',
'echo "<base64>" | <%= config.bin %> <%= command.id %> <assetId> --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<string[]> {
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)
})
}
Comment on lines +42 to +53
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Consider adding a TTY check or timeout for stdin reading.

If a user invokes --stdin without piping data (e.g., runs the command interactively), readStdin() will hang indefinitely waiting for input. Consider checking if stdin is a TTY and providing guidance, or adding a timeout.

♻️ Suggested improvement
  private async readStdin(): Promise<string[]> {
+   if (process.stdin.isTTY) {
+     this.error('No input piped to stdin. Use: echo "<base64>" | mplx core asset execute raw <assetId> --stdin')
+   }
+
    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)
    })
  }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private async readStdin(): Promise<string[]> {
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)
})
}
private async readStdin(): Promise<string[]> {
if (process.stdin.isTTY) {
this.error('No input piped to stdin. Use: echo "<base64>" | mplx core asset execute raw <assetId> --stdin')
}
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)
})
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/commands/core/asset/execute/raw.ts` around lines 42 - 53, The readStdin()
helper can hang if --stdin is used without piped input; update the readStdin
method to first check process.stdin.isTTY and immediately reject (or throw) with
a clear message advising to pipe data or use a different flag, and additionally
implement a configurable timeout (e.g., 5–10s) that rejects if no data arrives;
reference the readStdin function and the Promise resolution/rejection handlers
so you add the TTY check before attaching 'data'/'end' listeners and start a
timer that clears on 'end' and rejects on timeout.


public async run(): Promise<unknown> {
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
}
}
}
55 changes: 55 additions & 0 deletions src/commands/core/asset/execute/signer.ts
Original file line number Diff line number Diff line change
@@ -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<typeof ExecuteSigner> {
static override description = 'Show the asset signer PDA address and its SOL balance'

static override examples = [
'<%= config.bin %> <%= command.id %> <assetId>',
]

static override args = {
assetId: Args.string({ description: 'Asset ID to derive the signer PDA for', required: true }),
}

public async run(): Promise<unknown> {
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
}
}
}
99 changes: 99 additions & 0 deletions src/commands/core/asset/execute/transfer-asset.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
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<typeof ExecuteTransferAsset> {
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 %> <assetId> --asset <targetAssetId> --new-owner <address>',
]

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<unknown> {
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
}
}
}
92 changes: 92 additions & 0 deletions src/commands/core/asset/execute/transfer-sol.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
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<typeof ExecuteTransferSol> {
static override description = 'Transfer SOL from an asset\'s signer PDA to a destination address'

static override examples = [
'<%= config.bin %> <%= command.id %> <assetId> --amount 0.5 --destination <address>',
]

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<unknown> {
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
}
}
}
Loading
Loading