Programmable Pull Payments¶
A ComposablePolicy is a pull payment that runs two optional hooks between the pull and the settlement:
- Validation — a read-only on-chain assertion (via Lighthouse) that can veto the transaction if a condition isn't met.
- Forward — a token-transform step (via Meteora DLMM) that swaps the pulled input token into a different output token before delivery.
Both hooks are opt-in via sentinel values. A composable policy with both
disabled behaves like a PaymentPolicy but lives in its own PDA namespace and
routes through an intermediate ATA hop. Both families reuse the same
PolicyType enum (Subscription / Milestone / PayAsYouGo), the same
UserPayment account, and the same fee-distribution logic.
The lifecycle: pull → skim → pre-validate → forward → post-validate → settle¶
graph TD
Pull["1. PULL<br/>UserPayment PDA signs:<br/>user_token_account → intermediate_input_ata"]
Skim["2. SKIM (input-side)<br/>Protocol + gateway + scheduler fees<br/>deducted from intermediate_input_ata.<br/>Remaining = face amount for forward."]
PreVal["3. PRE-VALIDATE (optional)<br/>CPI into Lighthouse with stored<br/>assertion data + read-accounts.<br/>Fails the tx if assertion doesn't hold."]
Fwd["4. FORWARD (optional)<br/>CPI into Meteora DLMM:<br/>swap intermediate_input_ata → intermediate_output_ata.<br/>ByteRangeChecks pin the swap selector."]
PostVal["5. POST-VALIDATE (optional)<br/>CPI into Lighthouse with stored<br/>assertion data + read-accounts.<br/>Guards minimum output after forward."]
Settle["6. SETTLE<br/>Sweep intermediate_output → recipient.<br/>Fees already skimmed in phase 2 —<br/>settle moves only principal."]
Pull --> Skim --> PreVal --> Fwd --> PostVal --> Settle
classDef phase fill:#e3f2fd,stroke:#1565c0,stroke-width:2px
class Pull,Skim,PreVal,Fwd,PostVal,Settle phase
Key invariants enforced on-chain:
- Intermediate ATAs are owned by the
ComposablePolicyPDA, not theUserPaymentPDA. This decouples the intermediate signing authority from the user-source delegate — a forward program can only ever move transient intermediate balances, never the user's source funds. - Signer sanitization: validation and forward CPI builders do NOT forward
is_signerfromremaining_accounts. The fee payer (a Signer) cannot be re-passed to grant Lighthouse / DLMM unintended signer authority. - Allowlists (
programs/tributary/src/constants.rs): ALLOWED_FORWARD_PROGRAMS→ Meteora DLMM (LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo)ALLOWED_VALIDATION_PROGRAMS→ Lighthouse (L2TExMFKdjpN9kozasaurPirfHy9P8sbXoAN1qA3S95)- Emergency pause (
ProgramConfig.emergency_pause) blocksexecute_composablejust likeexecute_payment.
The sentinel convention¶
| Hook you want to disable | Sentinel value |
|---|---|
| No validation | ValidationSpec::Disabled (SDK: { disabled: {} }) |
| No forward | forward_config.target_program = PublicKey.default() |
The SDK accepts { disabled: {} } as the ValidationSpec for both
preValidation and postValidation. When forward is disabled,
instruction_constraint.data_checks should be empty and input_mint must
equal output_mint (no conversion step — it's a same-mint pull → sweep).
When to choose ComposablePolicy vs PaymentPolicy¶
| Use case | Choose | Reason |
|---|---|---|
| Plain subscription, milestone, or pay-as-you-go | PaymentPolicy | One CPI: transfer user → recipient + fees. Cheapest, simplest. |
| Auto-topup a hot wallet when its balance drops below a threshold | ComposablePolicy | Needs a Lighthouse assertion to veto the pull when the balance is fine. |
| Pull USDC from user, deliver WSOL to recipient | ComposablePolicy | Needs a Meteora DLMM forward between the pull and the settle. |
| Pull tokens only if an oracle / on-chain state condition holds | ComposablePolicy | Lighthouse can assert on any readable account (token account, mint, sysvar clock, raw account data). |
| "Pull X, deliver native SOL" (unwrap WSOL automatically) | ComposablePolicy | Forward to WSOL + FORWARD_FLAG_NATIVE_OUTPUT bit → closeAccount ships SOL to the recipient's system wallet. |
| Same-mint topup, no swap, no guard | PaymentPolicy | A composable with both hooks disabled is functionally equivalent but pays the PDA-hop overhead. Use PaymentPolicy unless you need the extension points. |
Where to next¶
- SDK surface —
getCreateComposablePolicyInstruction,executeComposable,ForwardConfig,ValidationSpec. - Lighthouse facade — build assertions with
lighthouse.tokenAccount(ata).amount(threshold, "<").build(). - Examples: Auto-topup guard · Swap & deliver · Native SOL topup.
- Deep technical reference → Protocol Reference → Composable Policy.