The problem with structured inputs
The typical way to express a Solana transaction is verbose. You constructTransactionInstruction objects with explicit program IDs, account metas, and serialized instruction data. For a simple token swap, that's 30-50 lines of boilerplate. For a multi-step operation involving swaps, staking, and liquidity provision, you're looking at hundreds of lines before any business logic.
This is fine for production code. But for expressing intent -- what you want to happen, not how -- it's needlessly complex. A developer prototyping an arbitrage strategy shouldn't need to manually derive associated token accounts to express "swap USDC to SOL."
The IVZA intent DSL
IVZA's IntentParser accepts plain-text descriptions of desired operations:
swap 100 USDC to SOL stake 50% of output SOL transfer 10 SOL to 7xKp...3nF
Each line is one intent. The parser tokenizes on whitespace, identifies the verb (swap, stake, transfer, unstake, provide-liquidity, remove-liquidity), extracts the amount and token mints, and produces a structured Intent object.
This isn't natural language processing. There's no LLM, no ambiguity resolution. It's a deterministic parser with a fixed grammar. If the input doesn't match the expected pattern, it fails with a clear error message. Predictability over magic.
Parser internals
The parser is implemented in both Rust (ivza-core::intent::parser) and TypeScript (@ivza/sdk IntentParser). Both follow the same logic:
pub fn parse_dsl(&self, input: &str) -> Result<Vec<Intent>> {
let mut intents = Vec::new();
for line in input.lines() {
let line = line.trim();
if line.is_empty() { continue; }
let tokens: Vec<&str> = line.split_whitespace().collect();
let verb = tokens.first()
.ok_or(IntentError::EmptyLine)?;
let intent = match verb.to_lowercase().as_str() {
"swap" => self.parse_swap(&tokens)?,
"stake" => self.parse_stake(&tokens)?,
"unstake" => self.parse_unstake(&tokens)?,
"transfer" => self.parse_transfer(&tokens)?,
"provide-liquidity" => self.parse_provide_lp(&tokens)?,
"remove-liquidity" => self.parse_remove_lp(&tokens)?,
_ => return Err(IntentError::UnknownVerb(
verb.to_string()
)),
};
intents.push(intent);
}
Ok(intents)
}Each verb has its own sub-parser. parse_swap expectsswap <amount> <token_a> to <token_b>. Amounts can be absolute (100) or relative (50%). Token names are resolved against a known mint registry -- SOL, USDC, USDT, and others map to their Solana mint addresses.
From intent to transaction graph
Parsing is only half the job. The IntentResolver takes parsed intents and converts them into a TransactionGraph -- the same graph structure that the dependency analyzer and scheduler operate on.
For a swap intent, the resolver:
- Derives the associated token accounts (ATAs) for both input and output mints
- Determines the correct swap program (Jupiter, Raydium, Orca) based on the pool registry
- Creates graph nodes with proper account access declarations: user wallet (signer), input ATA (write), output ATA (write), pool state (read), pool authority (read)
- If the output ATA doesn't exist, prepends a CreateAssociatedTokenAccount node with a dependency edge
fn resolve_swap(&self, intent: &SwapIntent)
-> Result<TransactionGraph>
{
let mut builder = TransactionGraphBuilder::new();
// ATA creation node (if needed)
let ata = derive_ata(&intent.owner, &intent.output_mint);
let create_ata = builder.add_node(
GraphNode::new("create_ata")
.write(ata)
.write(intent.owner)
.read(intent.output_mint)
);
// Swap node
let swap = builder.add_node(
GraphNode::new("swap")
.write(intent.input_ata)
.write(ata)
.read(intent.pool_state)
);
// ATA must exist before swap
builder.add_edge(create_ata, swap);
builder.build()
}Relative amounts and chaining
The DSL supports relative amounts that reference outputs of previous intents:
swap 500 USDC to SOL stake 50% of output SOL provide-liquidity with remaining SOL and 250 USDC
50% of output SOL creates an implicit dependency: the stake intent can't resolve its amount until the swap completes. The resolver detects this and adds a dependency edge from the swap node to the stake node.
remaining is syntactic sugar for "100% minus what was already consumed." If you swapped to 10 SOL and staked 5, "remaining SOL" resolves to 5 SOL.
These implicit dependencies are the interesting part. The user writes three lines. The resolver produces a graph with 5-7 nodes and 3-4 dependency edges. The scheduler finds that the ATA creation can run in parallel with the swap. The stake must wait. The LP provision must wait for both. Two parallel lanes emerge from three lines of text.
JSON fallback
The DSL is convenient for prototyping and CLI usage. For production integrations, the parser also accepts structured JSON:
{
"intents": [
{
"type": "swap",
"input_mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4w...",
"output_mint": "So11111111111111111111111111111111",
"amount": 500000000,
"slippage_bps": 50
}
]
}Same resolver, same graph output. The JSON path skips the tokenization step and goes directly to intent construction. Both paths produce identical TransactionGraphobjects.
The DSL makes IVZA approachable. The JSON makes it integrable. Both are parsed deterministically with explicit error messages. No ambiguity, no hallucination, no "I think you meant..." guessing.