Guide: Forward CPI Integration¶
A composable policy can transform the pulled token before delivering it to the recipient — pull USDC, swap to SOL via Meteora DLMM, deliver SOL. This guide shows how to wire any allowlisted forward program into a composable policy and pin it so only the exact instruction you approve can run.
Why constraints exist¶
The forward step is a CPI — Tributary calls an external program
(invoke_signed) with caller-supplied instruction data and accounts. Without
constraints, a malicious gateway could substitute any instruction on the
allowlisted program (e.g. a Meteora "withdraw" instead of "swap").
Tributary solves this with InstructionConstraint: you pin the instruction
selector (first 8 bytes) and optionally pin specific account positions
to concrete pubkeys. At execute time, the program validates both before the
CPI fires.
The three pieces¶
ForwardConfig
├── instructionConstraint: InstructionConstraint
│ ├── programId: PublicKey ← must be in ALLOWED_FORWARD_PROGRAMS
│ ├── dataChecks: ByteRangeCheck[4] ← pin the instruction selector
│ └── pinnedAccounts: PinnedAccount[2] ← pin specific account slots
├── inputMint: PublicKey ← the user's token (what's pulled)
├── outputMint: PublicKey ← what the recipient receives
└── forwardFlags: u8 ← bit0 = native SOL unwrap
Currently ALLOWED_FORWARD_PROGRAMS contains only Meteora DLMM
(LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo).
Step 1: Extract the discriminator¶
Every Anchor/SPL instruction starts with an 8-byte discriminator (the first
8 bytes of instruction.data). Pin those bytes at offset 0 to lock the
instruction type.
import DLMM from "@meteora-ag/dlmm";
const METEORA_DLMM_PUBKEY = new PublicKey(
"LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo"
);
// Build the swap ix once with a dummy user — you only need the data layout
const dummyIx = await buildSwapIx(PublicKey.default);
// The first 8 bytes are the Anchor instruction discriminator
const discriminator = dummyIx.data.slice(0, 8);
Step 2: Build the InstructionConstraint¶
At least one ByteRangeCheck must pin offset 0. The dataChecks array is
fixed-size (4 entries); pad unused slots with zero-length checks.
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) }, // unused
{ offset: 0, length: 0, expected: Buffer.alloc(8) }, // unused
{ offset: 0, length: 0, expected: Buffer.alloc(8) }, // unused
],
numPinnedAccounts: 0,
pinnedAccounts: [],
},
inputMint: USDC_MINT,
outputMint: NATIVE_MINT,
forwardFlags: 0,
};
PinnedAccounts (optional)¶
If the forward instruction has accounts that must be a specific pubkey (e.g. the pool address), pin them by their index in the forward-account slice:
// Suppose the DLMM pool is at index 2 in the swap instruction's accounts
pinnedAccounts: [
{ index: 2, pubkey: DLMM_POOL },
// up to 2 pins total
],
numPinnedAccounts: 1,
At execute time, Tributary checks
remaining_accounts[fwd_base + pin.index].pubkey == pin.pubkey for each
active pin. No duplicate indices allowed.
Step 3: The three settlement shapes¶
outputMint controls what happens after the forward CPI:
outputMint |
Forward | Shape | Behaviour |
|---|---|---|---|
== inputMint |
disabled (programId = default) |
deliver-no-transform | sweep input directly to recipient |
!= inputMint, concrete mint |
enabled | deliver-transform | swap → sweep output to recipient |
PublicKey.default() |
enabled | act mode | no output ATA, no sweep — forward acts on the input (e.g. deposit to a subaccount) |
Step 4: Execute — the forward accounts¶
At execute time, the caller supplies the live forward instruction data and
the forward accounts. The forward accounts come from the swap instruction's
keys — map them to AccountMeta:
const swapIx = await buildSwapIx(composablePolicyPDA); // real PDA as user
const forwardAccounts = swapIx.keys.map((k) => ({
pubkey: k.pubkey,
isSigner: false, // Tributary strips all is_signer from forward accounts
isWritable: true, // mark all writable — safe, avoids stale-IDL mismatches
}));
// remaining_accounts = [validation targets..., forward accounts...]
const remainingAccounts = [
...guard.accounts, // Lighthouse targets (empty if no validation)
...forwardAccounts, // DLMM swap accounts
];
const execIxs = await sdk.executeComposable(
composablePolicyPDA,
Buffer.from(swapIx.data), // the raw instruction data (selector must match)
new anchor.BN(amount), // pull amount (null for subscription)
remainingAccounts
);
Signer sanitization
Tributary forces isSigner: false on ALL forward (and validation) accounts.
This prevents the fee payer — a Signer — from granting signer authority to
the forward program via remaining_accounts. Do not attempt to pass signer
flags; they are stripped.
Common patterns¶
Meteora DLMM swap¶
See the Swap & Deliver example and the
Auto-DCA quickstart for complete working code,
including the hostFeeIn fix (rewrite the SystemProgram placeholder →
DLMM program id).
Native SOL delivery (WSOL unwrap)¶
Set forwardFlags = 1 (FORWARD_FLAG_NATIVE_OUTPUT) to unwrap WSOL to
native SOL via closeAccount after the swap. The recipient gets native SOL
in their system account — no WSOL ATA needed. See the
Native SOL topup example.
Same-mint topup (no swap)¶
When inputMint === outputMint and forward is disabled
(programId = PublicKey.default()), the policy is deliver-no-transform:
the pull goes straight to the recipient with no forward CPI. See the
AI agent budget quickstart.
Checklist before you ship¶
- [ ]
programIdis inALLOWED_FORWARD_PROGRAMS - [ ] At least one
ByteRangeCheckpins offset 0 (the discriminator) - [ ]
outputMintmatches your intended settlement shape - [ ] Forward accounts at execute time are marked
isWritable: true - [ ] Swap-level slippage (
minOutAmountin the swap ix) is set — or use post-validation as an output floor
Related¶
- Forward Hook reference — on-chain mechanics
- Allowlists & Sentinels — disabled forward
- SDK reference —
ForwardConfigtype