Skip to content

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

  • [ ] programId is in ALLOWED_FORWARD_PROGRAMS
  • [ ] At least one ByteRangeCheck pins offset 0 (the discriminator)
  • [ ] outputMint matches your intended settlement shape
  • [ ] Forward accounts at execute time are marked isWritable: true
  • [ ] Swap-level slippage (minOutAmount in the swap ix) is set — or use post-validation as an output floor