ComposablePolicy vs. PaymentPolicy¶
Tributary exposes two families of pull-payment policies that share the same
PolicyType enum, UserPayment account, PaymentGateway, and
fee-distribution logic — but differ sharply in their execution model.
Decision matrix¶
| Axis | PaymentPolicy | ComposablePolicy |
|---|---|---|
| Custody model | Non-custodial. User approves delegate; gateway pulls directly. | Non-custodial. Same delegate approval; pull routes through an intermediate ATA. |
| Execution | Single CPI: transfer from user → recipient + fees. |
5-phase: PULL (gross) → SKIM (input-side fees) → PRE-VALIDATE (opt) → FORWARD (opt) → POST-VALIDATE (opt) → SETTLE. |
| Supported hooks | None. | Validation (Lighthouse) + Forward (Meteora DLMM), each independently disableable. |
| Intermediate accounts | None. Funds move directly user_token → recipient_token. | Two ATAs owned by the ComposablePolicy PDA; created lazily, closed at end of every execute. |
| PDA seeds | ["payment_policy", user_payment, policy_id] |
["composable_policy", user_payment, policy_id] |
| ID counter | user_payment.created_policies_count |
user_payment.created_composable_count |
| CU cost | Lower — one Token CPI, no allocation. | Higher — ATA create + optional CPI(s) + fee math + two ATA closes. |
| Rent churn per execute | None. | Yes — intermediates are created and closed each call; rent returns to fee_payer but costs CU. |
| Fee math | shared::fees::calculate_fees |
Same — shared::fees::calculate_fees. Fee path is input-side (skimmed from the gross pull in Phase 1b, not from the output; ADR-0026). |
| Schedule math | shared::schedule::{validate_policy_execution, advance_policy} |
Same functions, same calendar-month arithmetic (M-04). |
| Permissionless execute | Yes — any gateway signer, the user, or the recipient. | Yes — same fee_payer ∈ {gateway.signer, user_payment.owner, recipient} constraint. |
| Use cases | Simple recurring billing, milestone escrows, pay-as-you-go with no conditional logic. | Programmable conditional payments: "topup if balance low", "pull USDC deliver WSOL", native-SOL delivery. |
| Forward / swap | Not supported. | Opt-in via ForwardConfig.target_program (Meteora DLMM). |
| Native SOL delivery | Not supported. | Opt-in via FORWARD_FLAG_NATIVE_OUTPUT (WSOL → SOL unwrap). |
| External CPI | None. | Lighthouse (read-only assertion) + Meteora DLMM (swap). Both hard-allowlisted. |
When to use PaymentPolicy¶
Pick PaymentPolicy when:
- You want the simplest pull payment — fixed schedule, fixed amount, fixed recipient, same-mint delivery.
- Recurring billing, milestone escrow, or pay-as-you-go with no conditional logic and no token conversion.
- CU budget is tight (one Token CPI vs. an ATA create/close cycle).
- You do not need to assert on-chain state before paying.
When to use ComposablePolicy¶
Pick ComposablePolicy when at least one of these is true:
- Conditional execution — the payment should only fire if some on-chain predicate holds (e.g. "recipient's hot wallet USDC balance is below threshold"). Use the validation hook + Lighthouse.
- Cross-token delivery — the user pays in token A but the recipient wants token B (e.g. pull USDC, deliver WSOL). Use the forward hook + Meteora DLMM.
- Native SOL delivery — recipient wants SOL, not WSOL. Use the forward
hook +
FORWARD_FLAG_NATIVE_OUTPUT. - Programmable auto-topup — pull and deliver the same token, but through a PDA-owned intermediate so future upgrades can insert a swap or assertion without changing the user-facing flow. Use the disabled-forward (same-mint topup) path.
Behavior with both hooks disabled¶
A ComposablePolicy with target_program = Pubkey::default() and
validation_program = SystemProgram is functionally close to a
PaymentPolicy — same pull, same fee split, same schedule math — but:
- It still pays the intermediate-ATA hop cost (create + close per execute).
- It lives in the
["composable_policy", …]PDA namespace. - Its ID comes from
created_composable_count, notcreated_policies_count.
If you never need hooks, prefer PaymentPolicy — it is strictly cheaper
and simpler. Reserve ComposablePolicy for policies that benefit from at
least one hook, or for which you anticipate adding one later.
Counter independence¶
Because the two policy families draw IDs from independent counters on
UserPayment, a single user can hold both a PaymentPolicy and a
ComposablePolicy with overlapping numeric IDs without collision:
UserPayment {
created_policies_count: 1 → PaymentPolicy #1 at ["payment_policy", …, 1]
created_composable_count: 1 → ComposablePolicy #1 at ["composable_policy", …, 1]
}
The seeds themselves are distinct, so even the address spaces do not overlap.
Migration path¶
There is no on-chain migration between PaymentPolicy and
ComposablePolicy — they are separate account types with separate PDAs.
To "convert", delete one and create the other. The user's UserPayment
account and delegate approval carry over unchanged, because both policy
families use the same pull-delegate (UserPayment PDA).