Skip to content

Expose fallible SPEL transaction builder API #244

Description

@3esmit

Problem

spel exposes spel::tx::execute_instruction(...), but the function is still CLI-shaped:

  • errors call process::exit(1) instead of returning Result
  • output is written directly to stdout/stderr
  • wallet access is forced through WalletCore::from_env()
  • transaction resolution, public/private transaction build, submit, dry-run rendering, and confirmation are coupled

That makes it hard to reuse SPEL from Rust integration/e2e tests or from another application. Tests need the same IDL-driven account/argument/PDA behavior as the CLI, but they also need structured errors, controlled wallet construction, and no process termination.

Current entry points involved:

  • spel-cli/src/tx.rs::execute_instruction
  • spel-cli/src/lib.rs::run

Request

Expose a library-grade API for IDL-driven public and private transactions.

The API should be intentionally small, but public and private
transactions should stay explicit. Suggested public surface:

use wallet::AccountIdentity;

pub struct SpelInstructionRequest<'a> {
    pub idl: &'a SpelIdl,
    pub instruction: &'a str,
    pub accounts: BTreeMap<String, Vec<AccountIdentity>>,
    pub args: BTreeMap<String, String>,
    pub extra_bins: BTreeMap<String, PathBuf>,
}

pub struct ResolvedPublicInstruction {
    pub program_id: ProgramId,
    pub accounts: Vec<AccountIdentity>,
    pub instruction_data: InstructionData,
}

pub struct ResolvedPrivateInstruction {
    pub program: ProgramWithDependencies,
    pub accounts: Vec<AccountIdentity>,
    pub instruction_data: InstructionData,
}

pub enum SpelTxError {
    UnknownInstruction { instruction: String },
    MissingInput { name: String },
    UnexpectedInputCount { name: String, expected: &'static str, actual: usize },
    ArgumentParse { name: String, source: Box<dyn std::error::Error + Send + Sync> },
    AccountParse { name: String, source: Box<dyn std::error::Error + Send + Sync> },
    PdaResolution { account: String, source: Box<dyn std::error::Error + Send + Sync> },
    ProgramLoad { path: PathBuf, source: Box<dyn std::error::Error + Send + Sync> },
    InstructionSerialization { source: Box<dyn std::error::Error + Send + Sync> },
    Wallet { source: Box<dyn std::error::Error + Send + Sync> },
    TransactionBuild { source: Box<dyn std::error::Error + Send + Sync> },
    Sequencer { source: Box<dyn std::error::Error + Send + Sync> },
    Confirmation { source: Box<dyn std::error::Error + Send + Sync> },
}

pub fn resolve_public_instruction(
    request: SpelInstructionRequest<'_>,
    program_id: ProgramId,
) -> Result<ResolvedPublicInstruction, SpelTxError>;

pub fn resolve_private_instruction(
    request: SpelInstructionRequest<'_>,
    program: ProgramWithDependencies,
) -> Result<ResolvedPrivateInstruction, SpelTxError>;

pub async fn build_public_transaction(
    resolved: ResolvedPublicInstruction,
    wallet: &WalletCore,
) -> Result<PublicTransaction, SpelTxError>;

pub async fn build_private_transaction(
    resolved: ResolvedPrivateInstruction,
    wallet: &WalletCore,
) -> Result<(PrivateTransaction, Vec<SharedSecretKey>), SpelTxError>;

pub async fn submit_public_transaction(
    transaction: PublicTransaction,
    wallet: &WalletCore,
) -> Result<HashType, SpelTxError>;

pub async fn submit_private_transaction(
    transaction: PrivateTransaction,
    wallet: &WalletCore,
) -> Result<HashType, SpelTxError>;

pub async fn confirm_transaction(
    tx_hash: HashType,
    wallet: &WalletCore,
) -> Result<LeeTransaction, SpelTxError>;

Exact names are not important. The important part is the boundary:

  • no process::exit
  • no mandatory env-based wallet lookup
  • no mandatory stdout/stderr output
  • keep public and private transaction paths explicit
  • split pure IDL/account/PDA/instruction resolution from wallet-dependent
    signing/proving/submission in each transaction path
  • reuse wallet::AccountIdentity directly for account identity, privacy, and
    signing intent instead of inventing a parallel SPEL account-reference enum
  • map each caller-supplied, non-PDA IDL account name to Vec<AccountIdentity>;
    require exactly one identity for fixed accounts and allow zero or more
    identities for rest accounts
  • preserve current CLI behavior for IDL parsing, Public/ and Private/
    account intent, PDA derivation, instruction serialization, signer discovery,
    --bin-* program-id fill-ins, dry-run rendering, and private transaction
    dependency binaries
  • preserve variadic/rest account behavior by allowing one IDL account name to
    resolve to zero or more account inputs
  • preserve PDA behavior by deriving PDA accounts from IDL seeds and resolved
    account/argument inputs rather than requiring callers to pass derived account
    IDs manually
  • keep the private resolved form carrying the main program binary plus
    dependencies, not only ProgramId; private transaction proving cannot be built
    from a program ID alone
  • allow callers to resolve only, build only, submit only, or submit and confirm
  • return structured SpelTxError values with enough context for tests and
    applications to assert on the failing input, not only on a broad category

Type justification

Each public type must earn its place:

  • SpelInstructionRequest is justified because it is the library boundary for
    raw caller input shared by both paths: IDL, instruction name, named account
    identities, typed string arguments, and dependency binaries used to auto-fill
    program-id arguments.
  • ResolvedPublicInstruction is justified because public transactions are
    identified by ProgramId, must not depend on a program binary, and should
    reject private account identities before wallet build.
  • ResolvedPrivateInstruction is justified because private transaction proving
    requires ProgramWithDependencies, can include private account identities, and
    returns shared secrets during build.
  • SpelTxError is justified because the current API exits the process. Variants
    should carry names, paths, counts, and source errors so callers can assert on
    the exact failure.

Avoid public types that only mirror transient implementation steps. In
particular, do not expose a combined ResolvedSpelInstruction or
BuiltSpelTransaction enum only to dispatch back into public/private branches.
That hides materially different transaction semantics behind one generic
function. Do not expose derived account IDs, signer account IDs, nonces, or
signing keys as part of the resolution result unless a caller-facing use case
requires them; wallet AccountManager already derives those from
AccountIdentity.

Wallet build-only API

SPEL should not copy wallet transaction construction. The wallet crate already
knows how to load account pre-states, derive nonces, discover public signing
keys, handle keycard signing, prepare private accounts, create proofs, and
return shared secrets. Add build-only wallet APIs, or expose equivalent wallet
internals, so SPEL can build without submitting:

pub async fn build_pub_tx(
    &self,
    accounts: Vec<AccountIdentity>,
    instruction_data: InstructionData,
    program_id: ProgramId,
) -> Result<PublicTransaction, ExecutionFailureKind>;

pub async fn build_private_tx(
    &self,
    accounts: Vec<AccountIdentity>,
    instruction_data: InstructionData,
    program: &ProgramWithDependencies,
) -> Result<(PrivateTransaction, Vec<SharedSecretKey>), ExecutionFailureKind>;

Existing submit APIs can then delegate to build-only APIs plus sequencer
submission. SPEL can call those wallet APIs rather than reimplementing
WalletCore/AccountManager signing, proof, nonce, pre-state, and
shared-secret logic.

Metadata and dry-run rendering

Dry-run output should not force a public SpelDryRunData or metadata type
unless there is a stable caller-facing contract for it. The CLI can preserve
current text/json dry-run behavior with a formatter over SpelInstructionRequest,
the matching IDL instruction, and either resolved public/private instruction
type. If a structured dry-run type becomes public later, justify it separately by
documenting its stable fields and consumers.

SPEL should use the wallet crate's AccountIdentity surface directly unless a
crate boundary makes that impossible. If a lower-level crate cannot depend on
wallet, keep the conversion at the SPEL transaction API boundary rather than
duplicating the model.

The reused wallet cases are:

  • AccountIdentity::Public
  • AccountIdentity::PublicNoSign
  • AccountIdentity::PublicKeycard
  • AccountIdentity::PrivateOwned
  • AccountIdentity::PrivateForeign
  • AccountIdentity::PrivatePdaOwned
  • AccountIdentity::PrivatePdaForeign
  • AccountIdentity::PrivateShared
  • AccountIdentity::PrivatePdaShared

Why

The wallet crate already provides reusable APIs for account creation, deployment submission, public transaction submission, private transaction submission, shared-secret return values, and transaction polling. The missing reusable layer is generic IDL-driven transaction resolution plus build-only access before submission. That may require splitting wallet public/private build from submit, or making the existing wallet build internals available to SPEL without copy-pasting them. Exposing that layer would let tests and applications exercise the same public and private transaction paths as spel without spawning a subprocess.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions