Skip to content

Add core execute commands#85

Closed
MarkSackerberg wants to merge 2 commits intomainfrom
feat/core-execute
Closed

Add core execute commands#85
MarkSackerberg wants to merge 2 commits intomainfrom
feat/core-execute

Conversation

@MarkSackerberg
Copy link
Contributor

Adding mplx core asset execute commands:

  • signer: Shows pda address and sol balance
  • transfer-sol: Transfer sol out of the PDA
  • transfer-asset: Transfer a core asset out of the PDA
  • transfer-token: Transfer tokens out of the PDA
  • raw: accepts base64 instructions to execute

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 18, 2026

Summary by CodeRabbit

  • New Features
    • Added new execute commands for Core Assets enabling users to execute instructions signed by asset signer PDAs.
    • Added ability to retrieve asset signer PDA information and SOL balance.
    • Added SOL transfer capability from asset signer PDAs to specified destinations.
    • Added SPL token transfer capability from asset signer PDAs.
    • Added asset ownership transfer functionality.
    • Added raw instruction execution support via base64-encoded instructions.

Walkthrough

This PR introduces a suite of CLI commands and utilities for executing operations on MPL Core Assets. It adds six subcommands under core/asset/execute: one to show the asset signer PDA, one for raw instruction execution, and four for specific operations (transferring SOL, SPL tokens, or asset ownership). The PR also includes instruction serialization/deserialization utilities and comprehensive test coverage.

Changes

Cohort / File(s) Summary
Core Asset Execute Commands
src/commands/core/asset/execute/index.ts, src/commands/core/asset/execute/signer.ts, src/commands/core/asset/execute/raw.ts
Base execute command, signer PDA retrieval and display, and raw base64-encoded instruction execution with stdin support and per-instruction deserialization error handling.
Core Asset Transfer Commands
src/commands/core/asset/execute/transfer-sol.ts, src/commands/core/asset/execute/transfer-token.ts, src/commands/core/asset/execute/transfer-asset.ts
Commands to transfer SOL from signer PDA to destination, transfer SPL tokens with ATA creation, and transfer Core Asset ownership; all include asset and collection resolution.
Instruction Serialization
src/lib/execute/deserializeInstruction.ts
Bidirectional serialization and deserialization of Solana instructions with binary format handling (programId, accounts with signer/writable flags, instruction data).
Test Suite
test/commands/core/core.execute.test.ts, test/commands/core/executehelpers.ts
Comprehensive command integration tests covering signer retrieval, SOL/token/asset transfers, raw instruction execution, and helper utilities for UMI setup and token creation.
Instruction Serialization Tests
test/lib/deserializeInstruction.test.ts
Unit tests validating round-trip serialization/deserialization and error handling for instruction objects with various account configurations.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related PRs

  • Merge in development #3: Introduces explorer utility functions (generateExplorerUrl) used across multiple execute commands for building transaction explorer URLs.

Suggested reviewers

  • blockiosaurus
🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Add core execute commands' accurately summarizes the main change: introducing multiple new CLI commands for core asset execution.
Description check ✅ Passed The description is directly related to the changeset, providing a clear bulleted list of the five new commands added and their purposes.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/core-execute
📝 Coding Plan
  • Generate coding plan for human review comments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 7

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/commands/core/asset/execute/index.ts`:
- Around line 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.

In `@src/commands/core/asset/execute/raw.ts`:
- Around line 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.

In `@src/commands/core/asset/execute/transfer-token.ts`:
- Around line 22-26: The amount flag can be negative because Flags.integer
allows negatives; add a positive-value validation early in this command (e.g.,
in the command's run method) to ensure the parsed amount > 0 before attempting
the transfer. Locate the static override flags declaration and the command's
entrypoint (run/execute) in transfer-token.ts, check the value of the parsed
amount flag (amount) and if it's <= 0 call this.error or throw a clear CLI error
indicating the amount must be a positive integer so the transfer logic never
runs with non-positive amounts.
- Around line 47-70: Replace the non-idempotent call to createAssociatedToken
with createIdempotentAssociatedToken so creating the destination ATA won't fail
if it already exists: change the creation of createAtaIx to call
createIdempotentAssociatedToken(umi, { mint: mintPubkey, owner:
destinationPubkey }) (and update imports to pull createIdempotentAssociatedToken
from mpl-toolbox and remove or replace the old createAssociatedToken import)
while keeping the rest of the flow (calling createAtaIx.getInstructions(), then
transferTokensIx.getInstructions()) unchanged.

In `@test/commands/core/core.execute.test.ts`:
- Line 19: Replace the hard-coded sleep expression "await new Promise(resolve =>
setTimeout(resolve, 10000))" (and the similar occurrences) with condition-based
waiting: implement a small retry/poll loop or use an existing test helper (e.g.,
waitFor / retryUntil) that repeatedly checks the real condition relevant to the
preceding action (for example, poll signer balance via getBalance until it
matches expected, or poll for the token account to exist) and resolves as soon
as the condition is met or a timeout is reached; update the tests that currently
use the fixed sleep at the mentioned locations to call this wait helper and
assert the condition instead of sleeping.
- Around line 61-65: The four tests repeat the same sequence of running the CLI
and extracting/asserting the signer PDA; create a shared helper (e.g.,
getSignerPdaFromCli or fetchSignerPda) that calls
runCli(['core','asset','execute','signer', assetId]), combines stdout/stderr,
runs stripAnsi and extractSignerPda, and returns the parsed signerPda (or
asserts truthy inside the helper); then replace the duplicated blocks in
core.execute.test.ts with calls to this helper (update each test to call
getSignerPdaFromCli(assetId) and assert or rely on the helper's assertion) and
remove the repeated code using the existing symbols runCli, stripAnsi,
extractSignerPda, and signerPda.

In `@test/commands/core/executehelpers.ts`:
- Line 20: The KEYPAIR_PATH constant uses process.cwd(), which makes tests
dependent on the current working directory; update KEYPAIR_PATH in
executehelpers.ts to build the path relative to the test file (use __dirname or
path.resolve with a relative path to the test-files directory) so the path is
stable regardless of where tests are run; change the initializer for
KEYPAIR_PATH to derive from __dirname (e.g., resolve/join from __dirname to the
test-files/key.json) while keeping the constant name KEYPAIR_PATH.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: ecb5e7af-1719-4a68-97d7-da8a83a9f81e

📥 Commits

Reviewing files that changed from the base of the PR and between c30370c and 168a584.

📒 Files selected for processing (10)
  • src/commands/core/asset/execute/index.ts
  • src/commands/core/asset/execute/raw.ts
  • src/commands/core/asset/execute/signer.ts
  • src/commands/core/asset/execute/transfer-asset.ts
  • src/commands/core/asset/execute/transfer-sol.ts
  • src/commands/core/asset/execute/transfer-token.ts
  • src/lib/execute/deserializeInstruction.ts
  • test/commands/core/core.execute.test.ts
  • test/commands/core/executehelpers.ts
  • test/lib/deserializeInstruction.test.ts

Comment on lines +14 to +16
public async run(): Promise<void> {
const {args, flags} = await this.parse(CoreAssetExecute)
}
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.

Comment on lines +42 to +53
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)
})
}
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.

Comment on lines +22 to +26
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 }),
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Add validation for positive amount.

Flags.integer allows negative values. Similar to transfer-sol.ts which validates amountInSol > 0, this command should validate that the amount is positive before attempting the transfer.

🛡️ Proposed fix
  public async run(): Promise<unknown> {
    const { args, flags } = await this.parse(ExecuteTransferToken)
    const { umi, explorer, chain } = this.context

+   if (flags.amount <= 0) {
+     this.error('Amount must be a positive number')
+   }
+
    const spinner = ora('Fetching asset...').start()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/commands/core/asset/execute/transfer-token.ts` around lines 22 - 26, The
amount flag can be negative because Flags.integer allows negatives; add a
positive-value validation early in this command (e.g., in the command's run
method) to ensure the parsed amount > 0 before attempting the transfer. Locate
the static override flags declaration and the command's entrypoint (run/execute)
in transfer-token.ts, check the value of the parsed amount flag (amount) and if
it's <= 0 call this.error or throw a clear CLI error indicating the amount must
be a positive integer so the transfer logic never runs with non-positive
amounts.

Comment on lines +47 to +70
// 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(),
]
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

Does mpl-toolbox createAssociatedToken fail if the account already exists?

💡 Result:

Yes, mpl-toolbox's createAssociatedToken fails if the associated token account already exists. The library provides both createAssociatedToken (standard SPL Associated Token Account creation, which errors if the account exists) and createIdempotentAssociatedToken (idempotent version that succeeds if it exists). Official docs distinguish them and show createAssociatedToken example without checks, while createTokenIfMissing is for general token accounts. SPL standard create fails on existing account.

Citations:


Replace createAssociatedToken with createIdempotentAssociatedToken.

createAssociatedToken fails if the associated token account already exists. Use createIdempotentAssociatedToken from mpl-toolbox instead, which succeeds whether the account exists or not. This prevents transaction failures when transferring to wallets that already have the token account.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/commands/core/asset/execute/transfer-token.ts` around lines 47 - 70,
Replace the non-idempotent call to createAssociatedToken with
createIdempotentAssociatedToken so creating the destination ATA won't fail if it
already exists: change the creation of createAtaIx to call
createIdempotentAssociatedToken(umi, { mint: mintPubkey, owner:
destinationPubkey }) (and update imports to pull createIdempotentAssociatedToken
from mpl-toolbox and remove or replace the old createAssociatedToken import)
while keeping the rest of the flow (calling createAtaIx.getInstructions(), then
transferTokensIx.getInstructions()) unchanged.


before(async () => {
await runCli(['toolbox', 'sol', 'airdrop', '100', 'TESTfCYwTPxME2cAnPcKvvF5xdPah3PY7naYQEP2kkx'])
await new Promise(resolve => setTimeout(resolve, 10000))
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Replace fixed sleeps with condition-based waiting to reduce flakiness.

Hard-coded delays make these integration tests timing-sensitive and slower than needed.

💡 Proposed pattern
+const waitFor = async (
+  predicate: () => Promise<boolean>,
+  timeoutMs = 20_000,
+  intervalMs = 500,
+) => {
+  const start = Date.now()
+  while (Date.now() - start < timeoutMs) {
+    if (await predicate()) return
+    await new Promise(resolve => setTimeout(resolve, intervalMs))
+  }
+  throw new Error('Timed out waiting for chain state')
+}

Then replace each fixed sleep with a state check relevant to the preceding action (e.g., signer balance observed, token account observed, etc.).

Also applies to: 69-69, 112-112, 198-198

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/commands/core/core.execute.test.ts` at line 19, Replace the hard-coded
sleep expression "await new Promise(resolve => setTimeout(resolve, 10000))" (and
the similar occurrences) with condition-based waiting: implement a small
retry/poll loop or use an existing test helper (e.g., waitFor / retryUntil) that
repeatedly checks the real condition relevant to the preceding action (for
example, poll signer balance via getBalance until it matches expected, or poll
for the token account to exist) and resolves as soon as the condition is met or
a timeout is reached; update the tests that currently use the fixed sleep at the
mentioned locations to call this wait helper and assert the condition instead of
sleeping.

Comment on lines +61 to +65
const { stdout: signerOut, stderr: signerErr } = await runCli([
'core', 'asset', 'execute', 'signer', assetId
])
const signerPda = extractSignerPda(stripAnsi(signerOut) + stripAnsi(signerErr))
expect(signerPda).to.be.ok
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

Extract signer-PDA lookup into a shared helper to remove duplication.

The same lookup/parse/assert sequence is repeated in four tests.

♻️ Proposed refactor
+const getSignerPda = async (assetId: string): Promise<string> => {
+  const { stdout, stderr } = await runCli(['core', 'asset', 'execute', 'signer', assetId])
+  const signerPda = extractSignerPda(stripAnsi(stdout) + stripAnsi(stderr))
+  if (!signerPda) throw new Error(`Could not extract signer PDA for asset ${assetId}`)
+  return signerPda
+}

Then replace each repeated block with:

-const { stdout: signerOut, stderr: signerErr } = await runCli([
-  'core', 'asset', 'execute', 'signer', assetId
-])
-const signerPda = extractSignerPda(stripAnsi(signerOut) + stripAnsi(signerErr))
-expect(signerPda).to.be.ok
+const signerPda = await getSignerPda(assetId)

Also applies to: 104-108, 134-138, 190-194

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/commands/core/core.execute.test.ts` around lines 61 - 65, The four tests
repeat the same sequence of running the CLI and extracting/asserting the signer
PDA; create a shared helper (e.g., getSignerPdaFromCli or fetchSignerPda) that
calls runCli(['core','asset','execute','signer', assetId]), combines
stdout/stderr, runs stripAnsi and extractSignerPda, and returns the parsed
signerPda (or asserts truthy inside the helper); then replace the duplicated
blocks in core.execute.test.ts with calls to this helper (update each test to
call getSignerPdaFromCli(assetId) and assert or rely on the helper's assertion)
and remove the repeated code using the existing symbols runCli, stripAnsi,
extractSignerPda, and signerPda.

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')
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Make test keypair path independent of current working directory.

Using process.cwd() on Line 20 can break when tests are invoked from another directory.

💡 Proposed fix
 import fs from 'node:fs'
-import path from 'node:path'
@@
-const KEYPAIR_PATH = path.join(process.cwd(), 'test-files', 'key.json')
+const KEYPAIR_PATH = new URL('../../../test-files/key.json', import.meta.url)
📝 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
const KEYPAIR_PATH = path.join(process.cwd(), 'test-files', 'key.json')
const KEYPAIR_PATH = new URL('../../../test-files/key.json', import.meta.url)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/commands/core/executehelpers.ts` at line 20, The KEYPAIR_PATH constant
uses process.cwd(), which makes tests dependent on the current working
directory; update KEYPAIR_PATH in executehelpers.ts to build the path relative
to the test file (use __dirname or path.resolve with a relative path to the
test-files directory) so the path is stable regardless of where tests are run;
change the initializer for KEYPAIR_PATH to derive from __dirname (e.g.,
resolve/join from __dirname to the test-files/key.json) while keeping the
constant name KEYPAIR_PATH.

@MarkSackerberg
Copy link
Contributor Author

Closing again - there's a better approach

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant