Native Output (FORWARD_FLAG_NATIVE_OUTPUT)¶
Bit 0 of ForwardConfig.forward_flags selects the native-output sweep
mode. When set, the post-swap settlement step unwraps the WSOL intermediate
into native SOL via closeAccount, instead of sweeping WSOL into the
recipient's WSOL ATA.
FORWARD_FLAG_NATIVE_OUTPUT: u8 = 1; // constants.rs
NATIVE_MINT: Pubkey = "So11111111111111111111111111111111111111112";
When to use¶
Use native-output when the recipient wants native SOL, not WSOL. Common cases:
- A wallet that does not maintain a WSOL ATA and prefers SOL directly.
- A hot-wallet topup flow that pays gas in SOL.
- Recipient is a smart contract / program that consumes lamports, not SPL-token balances.
Without this flag, the recipient receives WSOL and must separately unwrap
it (createAccount + closeAccount) to spend it as gas.
Create-time guard¶
validate_forward_config enforces:
if forward_config.is_native_output() {
require!(
forward_config.output_mint == NATIVE_MINT,
TributaryError::NativeOutputRequiresWsol,
);
}
Auto-unwrapping any output_mint other than WSOL would closeAccount an
unrelated token account — the guard makes the invariant explicit.
Execution difference¶
In normal mode the post-swap sweep is a transfer_checked:
intermediate_output_ata (WSOL)
│ transfer_checked(sweep_amount)
▼
recipient_token_account (recipient's WSOL ATA)
In native-output mode the sweep is a closeAccount:
intermediate_output_ata (WSOL, owned by ComposablePolicy PDA)
│ closeAccount
│ authority = ComposablePolicy PDA
│ destination = recipient_token_account (== recipient's SYSTEM wallet)
▼
recipient_token_account (recipient's system account, native SOL)
↑ receives sweep_amount lamports (the WSOL value)
↑ PLUS the closed ATA's rent lamports (side-effect bonus)
Source: process_output_and_sweep in execute_composable.rs.
Fees are input-side (Phase 1b)
Despite the output-focused flow above, fees are not taken from the
output path. Per ADR-0026, composable fees are input-side — skimmed from
the gross pull (USDC/input_mint) in Phase 1b, before the forward
swap or native-output unwrap runs. The protocol and gateway fee accounts
hold input_mint tokens (USDC in this example), not WSOL. After skim,
intermediate_input holds exactly face, which is what the forward
consumes. The output side only delivers the post-swap principal to the
recipient.
The sweep_amount returned for accounting is the face amount that made it
through the forward (fees already deducted upstream); the rent bonus shipped
by closeAccount is excluded from composable_policy.total_output.
Recipient validation¶
The recipient_token_account argument has dual meaning:
| Mode | What it must be |
|---|---|
| Normal | The recipient's output_mint ATA (mint == output_mint AND owner == recipient) |
| Native-output | The recipient's system wallet (key == composable_policy.recipient) |
Anchor constraints are static, so recipient_token_account is declared as
UncheckedAccount and the handler replicates the two normal-mode checks
(mint at bytes 0..32, owner at bytes 32..64 of the SPL Token layout)
inline. In native-output mode the handler asserts:
require!(
ctx.accounts.recipient_token_account.key()
== ctx.accounts.composable_policy.recipient,
TributaryError::Unauthorized,
);
Security — destination pinned on-chain¶
The closeAccount destination is constrained on-chain to equal
composable_policy.recipient. There is no drain vector: a gateway cannot
redirect the unwrap to itself, because the handler (not the caller) chooses
the destination and validates it against stored policy state.
The rejected alternative — a generic Token/wrap forward program that
performed the unwrap itself — would have let the gateway redirect
closeAccount's destination to its own wallet. Pinning the destination in
the Tributary handler closes that vector. See reports/native-output-sweep.md
and bean tributary-hgp7 for the full write-up.
Post-sweep cleanup¶
closeAccount zeroes the WSOL intermediate, so the handler's
"verify intermediates empty" step skips the output-side check when
native_output is true:
if !native_output {
let output_check = read_token_amount(&intermediate_output_ata)?;
require!(output_check == 0, TributaryError::InsufficientBalance);
}
Similarly, the final intermediate-close loop skips re-closing the output intermediate (it no longer exists). The input intermediate is still closed normally to return rent to the fee payer.