Example: Native SOL Topup (WSOL → SOL unwrap)¶
Use case. Same as Swap & Deliver, but the recipient wants to be paid in native SOL, not wrapped SOL. Most recipient wallets (exchanges, custody, treasuries) don't want to deal with unwrapping WSOL themselves.
This is the swap-and-deliver flow plus one flag bit:
ForwardConfig.forwardFlags & FORWARD_FLAG_NATIVE_OUTPUT. When set, the
post-swap sweep does not transfer_checked into the recipient's WSOL ATA
— it closeAccounts the intermediate WSOL ATA directly into the recipient's
system wallet, shipping the WSOL value as native SOL.
graph LR
Cold["coldWallet<br/>(user, USDC)"] -->|"pull USDC (gross)"| In["intermediate USDC ATA"]
In -->|"skim fees (USDC, input-side)"| Fees["fee ATAs<br/>(protocol + gateway)"]
In -->|"Meteora DLMM swap"| Out["intermediate WSOL ATA<br/>(owned by ComposablePolicy PDA)"]
Out -->|"closeAccount<br/>(FORWARD_FLAG_NATIVE_OUTPUT)"| Hot["hotWallet<br/>(recipient, SYSTEM wallet)<br/>receives native SOL"]
classDef user fill:#e8f5e8,stroke:#1b5e20
classDef pda fill:#e3f2fd,stroke:#1565c0
classDef native fill:#fce4ec,stroke:#880e4f
class Cold,Hot user
class In,Out,Fees pda
The FORWARD_FLAG_NATIVE_OUTPUT bit¶
Constraints enforced on-chain (validate_forward_config):
- The flag REQUIRES
output_mint == NATIVE_MINT(So111…111). Setting the flag with any other output mint is rejected at policy creation (NativeOutputRequiresWsol). - The recipient's
recipient_token_accountpassed at execute time MUST equalcomposable_policy.recipient(the system wallet), NOT a token account. The handler validates this explicitly because Anchor constraints can't be conditional.
The sweep implementation (process_output_and_sweep):
normal mode: transfer_checked(intermediate_output → recipient_ata, sweep_amount, decimals)
NATIVE_OUTPUT mode: closeAccount(intermediate_output → recipient_system_wallet)
closeAccount ships the entire remaining WSOL value (= sweep_amount) as
native SOL, plus the rent lamports of the closed ATA (a side-effect bonus to
the recipient). The destination is constrained on-chain to equal
composable_policy.recipient, so there is no drain vector.
Full code example¶
import * as anchor from "@coral-xyz/anchor";
import {
PublicKey,
SystemProgram,
Transaction,
TransactionInstruction,
sendAndConfirmTransaction,
} from "@solana/web3.js";
import { NATIVE_MINT, getAssociatedTokenAddressSync } from "@solana/spl-token";
import DLMM from "@meteora-ag/dlmm";
import {
Tributary,
lighthouse,
getPreValidationPda,
getPostValidationPda,
LIGHTHOUSE_PROGRAM_ID,
} from "@tributary-so/sdk";
const SWAP_INPUT_AMOUNT = 50_000_000; // 50 USDC
const USDC_MINT = new PublicKey("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v");
const METEORA_DLMM_PUBKEY = new PublicKey(
"LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo"
);
const METEORA_DLMM_SOL_USDC_POOL = new PublicKey("<your pool address>");
// bit 0
const FORWARD_FLAG_NATIVE_OUTPUT = 1;
1. Build the swap ix (same as swap-and-deliver)¶
const dlmmPool = await DLMM.create(connection, METEORA_DLMM_SOL_USDC_POOL, {
cluster: "mainnet-beta",
skipSolWrappingOperation: true,
});
const swapForY = USDC_MINT.equals(dlmmPool.tokenX.publicKey);
const binArrays = await dlmmPool.getBinArrayForSwap(swapForY);
const quote = dlmmPool.swapQuote(
new anchor.BN(SWAP_INPUT_AMOUNT),
swapForY,
new anchor.BN(100),
binArrays
);
async function buildSwapIx(user: PublicKey): Promise<TransactionInstruction> {
const swapTx = await dlmmPool.swap({
lbPair: METEORA_DLMM_SOL_USDC_POOL,
inToken: USDC_MINT,
outToken: NATIVE_MINT,
inAmount: new anchor.BN(SWAP_INPUT_AMOUNT),
minOutAmount: quote.minOutAmount,
user,
binArraysPubkey: quote.binArraysPubkey as PublicKey[],
});
const found = swapTx.instructions.find((i) =>
i.programId.equals(METEORA_DLMM_PUBKEY)
)!;
const keys = found.keys.map((k) =>
k.pubkey.equals(SystemProgram.programId)
? {
pubkey: METEORA_DLMM_PUBKEY,
isSigner: k.isSigner,
isWritable: k.isWritable,
}
: k
);
return new TransactionInstruction({
keys,
programId: found.programId,
data: found.data,
});
}
const discriminator = Array.from(
(await buildSwapIx(PublicKey.default)).data.slice(0, 8)
);
2. Create the policy with FORWARD_FLAG_NATIVE_OUTPUT¶
const forwardConfig = {
inputMint: USDC_MINT,
outputMint: NATIVE_MINT, // REQUIRED — NATIVE_OUTPUT requires WSOL output
forwardFlags: FORWARD_FLAG_NATIVE_OUTPUT, // ← bit 0 set
instructionConstraint: {
programId: METEORA_DLMM_PUBKEY,
numDataChecks: 1,
dataChecks: [
{ offset: 0, length: 8, expected: discriminator },
{ offset: 0, length: 0, expected: [0, 0, 0, 0, 0, 0, 0, 0] },
{ offset: 0, length: 0, expected: [0, 0, 0, 0, 0, 0, 0, 0] },
{ offset: 0, length: 0, expected: [0, 0, 0, 0, 0, 0, 0, 0] },
],
numPinnedAccounts: 0,
pinnedAccounts: [
{ index: 0, pubkey: PublicKey.default },
{ index: 0, pubkey: PublicKey.default },
],
},
};
const createIx = await sdk.getCreateComposablePolicyInstruction(
USDC_MINT,
hotWallet.publicKey, // recipient — the SYSTEM wallet that will receive SOL
gatewayPDA,
policyType,
"Native SOL topup",
forwardConfig,
{ programCall: { programId: LIGHTHOUSE_PROGRAM_ID } }, // preValidation
[hotWallet.publicKey], // prePinnedAccounts
guard.data // preValidationData
// postValidation: disabled (default)
);
3. Execute — recipient_token_account is the system wallet¶
This is the key difference from the WSOL-delivery variant: the
recipient_token_account field in the executeComposable accounts struct is
the recipient's system wallet (hotWallet.publicKey), not a WSOL ATA.
The handler checks recipient_token_account == composable_policy.recipient
when NATIVE_OUTPUT is set.
Bypass the SDK's recipient-ATA derivation
The SDK's executeComposable() auto-derives recipientTokenAccount as
getAssociatedTokenAddressSync(outputMint, recipient). For NATIVE_OUTPUT
that derivation is wrong — it would hand closeAccount a WSOL ATA instead
of the system wallet. Two options: - Use the low-level program.methods.executeComposable(...) path and pass
recipientTokenAccount: hotWallet.publicKey directly (see the test
suite for the exact accounts struct). - Wrap executeComposable() and override the resolved account after the
fact.
Low-level path (matches tests/ pattern):
const swapIx = await buildSwapIx(composablePolicyPDA);
const forwardAccounts = swapIx.keys.map((k) => ({
pubkey: k.pubkey,
isSigner: false,
isWritable: true,
}));
// ValidationPda is a named account post-ADR-0016; remaining_accounts is
// the bare [target, ...forward] slice (no leading ValidationPda entry).
const { address: preValidationPda } = getPreValidationPda(
composablePolicyPDA,
program.programId
);
const { address: postValidationPda } = getPostValidationPda(
composablePolicyPDA,
program.programId
);
const remainingAccounts = [...guard.accounts, ...forwardAccounts];
const ix = await program.methods
.executeComposable(Buffer.from(swapIx.data), new anchor.BN(SWAP_INPUT_AMOUNT))
.accountsStrict({
feePayer: coldWallet.publicKey,
paymentsDelegate: paymentsDelegatePDA,
composablePolicy: composablePolicyPDA,
userPayment: userPaymentPDA,
gateway: gatewayPDA,
config: configPDA,
preValidationProgram: LIGHTHOUSE_PROGRAM_ID,
postValidationProgram: SystemProgram.programId,
preValidationPda,
postValidationPda,
userTokenAccount: coldWalletUsdcAta,
mint: USDC_MINT,
outputMint: NATIVE_MINT,
intermediateInputTokenAccount: getAssociatedTokenAddressSync(
USDC_MINT,
composablePolicyPDA,
true
),
intermediateOutputTokenAccount: getAssociatedTokenAddressSync(
NATIVE_MINT,
composablePolicyPDA,
true
),
recipientTokenAccount: hotWallet.publicKey, // ← SYSTEM WALLET, not ATA
gatewayFeeAccount: feeRecipientUsdcAta, // input-side (USDC, ADR-0026)
protocolFeeAccount: adminUsdcAta,
tokenProgram: TOKEN_PROGRAM_ID,
associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID,
systemProgram: SystemProgram.programId,
})
.remainingAccounts(remainingAccounts)
.instruction();
Fees are input-side (USDC, ADR-0026)
Composable fees are always skimmed from the intermediate input (USDC)
before the forward swap runs, not from the output. gatewayFeeAccount and
protocolFeeAccount are USDC ATAs, not WSOL.
When to use native SOL vs WSOL delivery¶
| Recipient wants | Use | forwardFlags |
|---|---|---|
| WSOL (will unwrap themselves, or is a Solana-native DeFi wallet) | Swap & Deliver | 0 |
| Native SOL (exchange, custody, EOA-style wallet) | Native SOL topup | FORWARD_FLAG_NATIVE_OUTPUT (1) |
Native SOL delivery costs one extra closeAccount CPI and skips the
recipient ATA requirement. WSOL delivery leaves the recipient with a wrapped
balance they must unwrap later (or keep for DeFi use).
Failure modes¶
| Condition | Outcome |
|---|---|
outputMint != NATIVE_MINT while flag set |
Rejected at creation (NATIVE_OUTPUTRequiresWsolOutputMint). |
recipientTokenAccount != composable_policy.recipient at execute |
Unauthorized. |
Swap selector doesn't match the pinned ByteRangeCheck |
Tx reverts. |
| Lighthouse assertion fails | Tx reverts before the swap. |
Reference¶
FORWARD_FLAG_NATIVE_OUTPUT→programs/tributary/src/constants.rs:24- Sweep logic →
process_output_and_sweepinprograms/tributary/src/instructions/composable/execute_composable.rs - Swap & Deliver — the WSOL-delivery variant.
- SDK surface —
executeComposableand therecipient_token_accountoverride caveat.