Example: Swap & Deliver (USDC → WSOL via Meteora DLMM)¶
Use case. A service charges in USDC (the input mint the user holds), but the recipient wants to be paid in WSOL. The composable policy pulls USDC from the user, swaps it through a Meteora DLMM pool into WSOL, and delivers WSOL (minus fees) to the recipient's WSOL ATA.
This is a composable policy with:
- Forward enabled —
programId = METEORA_DLMM_PUBKEYon theinstructionConstraint, with aByteRangeCheckpinning the swap instruction discriminator at offset 0. - Optional validation — same pattern as the topup guard (e.g. only swap when the recipient's WSOL balance is below a threshold).
- Input-side fees — protocol and gateway fees are skimmed from the gross USDC pull in Phase 1b (ADR-0026), before the forward runs. The swap and deliver phases move only the net principal.
graph LR
Cold["coldWallet<br/>(user, USDC)"] -->|"pull USDC (gross)<br/>(UserPayment PDA signs)"| In["intermediate USDC ATA"]
In -->|"skim fees<br/>(input-side)"| Fee["protocol fee + gateway fee<br/>(USDC)"]
PreVal["Lighthouse CPI<br/>(pre‑validation)"] -.->|"assertion → continue"| In
In -->|"swap via DLMM<br/>(ComposablePolicy PDA signs)"| Out["intermediate WSOL ATA"]
PostVal["Lighthouse CPI<br/>(post‑validation)"] -.->|"assertion → continue"| Out
Out -->|"sweep WSOL"| Hot["hotWallet<br/>(recipient, WSOL ATA)"]
classDef user fill:#e8f5e8,stroke:#1b5e20
classDef pda fill:#e3f2fd,stroke:#1565c0
classDef val fill:#fff3e0,stroke:#e65100
class Cold,Hot user
class In,Out pda
class PreVal,PostVal val
class Fee user
Constants¶
import DLMM from "@meteora-ag/dlmm";
import { NATIVE_MINT } from "@solana/spl-token";
// The only program currently in ALLOWED_FORWARD_PROGRAMS.
const METEORA_DLMM_PUBKEY = new PublicKey(
"LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo"
);
const METEORA_DLMM_SOL_USDC_POOL = new PublicKey("<your pool address>");
const USDC_MINT = new PublicKey("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v");
const SWAP_INPUT_AMOUNT = 50_000_000; // 50 USDC
Pool choice matters
The pool you pass to pool.swap() must have USDC_MINT as one leg and
NATIVE_MINT as the other. Tributary does not validate the pool itself —
only that the forward program id is allowlisted and the instruction selector
matches the pinned ByteRangeCheck.
1. Build the DLMM swap instruction¶
The swap user MUST be the ComposablePolicy PDA — it owns both intermediate
ATAs, and Tributary's run_forward_cpi promotes it to signer via
invoke_signed.
// Load the pool with skipSolWrappingOperation — pool.swap() otherwise appends
// a WSOL wrap/unwrap post-instruction; Tributary manages the intermediates
// itself.
const dlmmPool = await DLMM.create(connection, METEORA_DLMM_SOL_USDC_POOL, {
cluster: "mainnet-beta",
skipSolWrappingOperation: true,
});
// swapForY = true ⟹ in-token is X (we sell USDC, buy WSOL/Y).
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), // 1% slippage
binArrays
);
The ComposablePolicy PDA isn't known until after creation, so build the swap
ix in two places: once at creation (to extract its discriminator) and again at
execute (with the real user). Wrap it in a helper:
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, // slippage protection at the swap layer
user,
binArraysPubkey: quote.binArraysPubkey as PublicKey[],
});
// pool.swap() returns [CU-estimation ix, idempotent ATA-create ix, swap ix].
// Keep ONLY the instruction whose programId == DLMM.
const found = swapTx.instructions.find((i) =>
i.programId.equals(METEORA_DLMM_PUBKEY)
);
if (!found) throw new Error("DLMM swap instruction not found");
// hostFeeIn fix: the SDK passes hostFeeIn: null → Anchor serializes that as
// the System Program id. The DLMM program rejects a System-Program-owned
// host_fee_in. Rewrite that one account meta to the DLMM program id itself
// (Meteora's own CLI/tests use that as the "no host fee" placeholder).
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,
});
}
// Build once just to extract the discriminator for the ByteRangeCheck.
const discriminator = (await buildSwapIx(PublicKey.default)).data.slice(0, 8);
2. Create the composable policy¶
Forward enabled: programId = METEORA_DLMM_PUBKEY on the
instructionConstraint, at least one ByteRangeCheck pins the
discriminator at offset 0.
const forwardConfig = {
instructionConstraint: {
programId: METEORA_DLMM_PUBKEY,
numDataChecks: 1,
dataChecks: [
{ offset: 0, length: 8, expected: Buffer.from(discriminator) },
{ offset: 0, length: 0, expected: Buffer.alloc(8) },
{ offset: 0, length: 0, expected: Buffer.alloc(8) },
{ offset: 0, length: 0, expected: Buffer.alloc(8) },
],
numPinnedAccounts: 0,
pinnedAccounts: [],
},
inputMint: USDC_MINT,
outputMint: NATIVE_MINT,
forwardFlags: 0, // WSOL ATA delivery — see native-sol-topup.md for the unwrap variant
};
// Optional Lighthouse guard: only swap when recipient WSOL is below 1 WSOL.
const guard = lighthouse
.tokenAccount(hotWalletWsolAta)
.amount(1_000_000_000, "<")
.build();
const createIx = await sdk.getCreateComposablePolicyInstruction(
USDC_MINT,
hotWallet.publicKey, // recipient
gatewayPDA,
policyType,
"Topup WSOL swap",
forwardConfig,
{ programCall: { programId: LIGHTHOUSE_PROGRAM_ID } }, // preValidation
guard.accounts, // prePinnedAccounts — owner-declared Lighthouse target accounts
guard.data // preValidationData
);
Why pin the discriminator?
Without a ByteRangeCheck at offset 0, a malicious gateway could substitute
any instruction data at execute time — the program ID is allowlisted, but
DLMM exposes other instructions besides swap. Pinning the first 8 bytes
locks the swap selector and refuses any other instruction shape.
3. Execute (permissionless)¶
The caller supplies:
instructionData= the raw DLMM swap ix data (the same bytes whose first 8 must match the pinnedByteRangeCheck).forwardAmount= the USDC pull size (required for PayAsYouGo; passnullfor subscription/onetime).remaining_accounts=[...guard.accounts, ...forwardAccounts]— theValidationPdais in theaccountsStrictmap; the remaining accounts slice is just the Lighthouse target accounts + forward accounts.
// Two distinct intermediates (input_mint != output_mint), both owned by the
// ComposablePolicy PDA. The swap draws USDC from the input ATA and sends
// WSOL to the output ATA; fees + sweep then move WSOL to the recipient.
const swapIx = await buildSwapIx(composablePolicyPDA);
const forwardAccounts = swapIx.keys.map((k) => ({
pubkey: k.pubkey,
isSigner: false,
// Mark ALL forward accounts writable. The DLMM program mutates several
// accounts that dlmm-sdk@0.7.7's IDL marks read-only (e.g.
// bin_array_bitmap_extension, oracle). The runtime permits marking an
// account writable even if the callee never writes it, so this is safe
// and sidesteps the stale-IDL mutability mismatch.
isWritable: true,
}));
const remainingAccounts = [
...guard.accounts, // Lighthouse target accounts (no leading ValidationPda)
...forwardAccounts, // DLMM swap accounts (includes the self-listed DLMM program)
];
const [execIx] = await sdk.executeComposable(
composablePolicyPDA,
Buffer.from(swapIx.data),
new anchor.BN(SWAP_INPUT_AMOUNT),
remainingAccounts
);
// Returns [executeInstruction] — also ATA ensures for recipient + fee recipients
minOutputAmount (removed)¶
ForwardConfig.minOutputAmount was removed in v2.1. The ByteRangeCheck
pins the swap instruction selector, so swap-level slippage (minOutAmount on
pool.swapQuote) is the caller's responsibility — it's encoded inside the
forward instruction data that the CPI executes verbatim.
For net-level guarantees (output after fees), use the post-validation
hook: a Lighthouse assertion against the intermediate output ATA balance
after the swap but before the sweep. This replaces the old inline
minOutputAmount field.
const netGuard = lighthouse
.tokenAccount(intermediateOutputAta)
.amount(desiredMinOut, ">=")
.build();
const createIx = await sdk.getCreateComposablePolicyInstruction(
USDC_MINT,
hotWallet.publicKey,
gatewayPDA,
policyType,
"Topup WSOL swap",
forwardConfig,
preValidationSpec,
prePinnedAccounts,
preValidationData,
{ programCall: { programId: LIGHTHOUSE_PROGRAM_ID } }, // postValidation
netGuard.accounts, // postPinnedAccounts
netGuard.data // postValidationData
);
Slippage protection
The DLMM minOutAmount inside the swap ix is the primary
price-slippage defence. The post-validation hook is an optional backstop
on the delivered amount; leave it disabled if swap-level slippage already
covers your requirements.
Failure modes¶
| Condition | Outcome |
|---|---|
Swap selector doesn't match the pinned ByteRangeCheck |
DiscriminatorCheckRequired / ByteRangeCheckFailed → tx reverts. |
| PayAsYouGo period cap exhausted | Rejected before the swap CPI runs. |
| Lighthouse assertion fails (recipient already has enough WSOL) | Tx reverts before the swap. |
Insufficient delegate amount on userTokenAccount |
InsufficientDelegatedAmount. |
| Pool moves adversarially between quote and execute | minOutAmount in the swap ix (or a post‑validation assertion) protects you. |
Reference¶
- Working test:
tests/topup-balance-swap.test.ts(runs against Surfpool with a mainnet-forked DLMM pool). - SDK surface —
executeComposableandremaining_accountslayout. - Next: Native SOL topup — same flow but unwraps WSOL
to native SOL via the
FORWARD_FLAG_NATIVE_OUTPUTbit.