Allowlists & Sentinels¶
ComposablePolicy ships with two hard-coded program allowlists and a pair of sentinel conventions that let a policy disable either hook without special-cased branches throughout the codebase. This page documents both and the create-time / execute-time checks that enforce them.
Allowlists¶
Defined in programs/tributary/src/constants.rs:
pub const ALLOWED_FORWARD_PROGRAMS: &[Pubkey] =
&[pubkey!("LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo")]; // Meteora DLMM
pub const ALLOWED_VALIDATION_PROGRAMS: &[Pubkey] =
&[pubkey!("L2TExMFKdjpN9kozasaurPirfHy9P8sbXoAN1qA3S95")]; // Lighthouse
| Constant | Currently contains | Used by |
|---|---|---|
ALLOWED_FORWARD_PROGRAMS |
Meteora DLMM (1 entry) | validate_forward_config, execute handler |
ALLOWED_VALIDATION_PROGRAMS |
Lighthouse (1 entry) | create_composable_policy |
Allowlisting exists because both hooks receive invoke_signed-style
authority over the ComposablePolicy PDA's intermediate ATAs. An arbitrary
program in either slot could move transient balances to attacker accounts.
Hard-allowlisting caps the blast radius to two well-audited programs whose
instruction semantics Tributary understands (and, in the forward case, pins
via ByteRangeCheck discriminator checks — see
forward-hook.md).
Sentinel conventions¶
| Hook | Disabled when… | Sentinel |
|---|---|---|
| Forward | instruction_constraint.program_id == Pubkey::default() |
Pubkey::default() |
| Validation | ValidationSpec::Disabled |
enum variant |
The two hooks use different sentinel conventions. Forward uses a
pubkey sentinel (Pubkey::default() on instruction_constraint.program_id)
since program ID is the single gate. Validation uses an enum variant
(ValidationSpec::Disabled) because the spec carries no pubkey — when
disabled no assertion data is stored, no ValidationPda is allocated, and
the caller passes SystemProgram as the program account.
Forward-disabled invariants¶
When instruction_constraint.program_id == Pubkey::default():
ic.num_data_checks == 0(no forward instruction selector to pin).output_mint == input_mint(no conversion step — the "same-mint topup" pattern. The intermediate is funded by the pull and swept directly).- Execute handler skips both the byte-range check and
run_forward_cpi.
Validation-disabled invariants¶
When the spec is ValidationSpec::Disabled:
validation_data.is_empty()(no assertion to store — enforced byvalidate_initat create time).- No
ValidationPdaaccount is allocated (neither pre nor post). - Execute handler skips Phase 2 (pre-validation) and Phase 4
(post-validation) entirely; it consumes zero
remaining_accountsfor validation, and the caller passesSystemProgramas the program account.
Create-time checks¶
Forward config (validate_forward_config)¶
The forward-leg rules are extracted into a unit-testable helper. The current
function reads num_data_checks and program_id through the nested
instruction_constraint:
pub fn validate_forward_config(forward_config: &ForwardConfig) -> Result<()> {
let ic = &forward_config.instruction_constraint;
let forward_disabled = ic.is_disabled();
require!(
forward_disabled || ALLOWED_FORWARD_PROGRAMS.contains(&ic.program_id),
TributaryError::InvalidForwardProgram
);
if forward_disabled {
require!(ic.num_data_checks == 0,
TributaryError::InsufficientByteRangeChecks);
require!(forward_config.output_mint == forward_config.input_mint,
TributaryError::ForwardDisabledRequiresSameMint);
} else {
require!(ic.num_data_checks >= 1
&& ic.num_data_checks <= MAX_BYTE_RANGE_CHECKS as u8,
TributaryError::InsufficientByteRangeChecks);
require!(ic.has_effective_pins(),
TributaryError::DegenerateForwardPins);
require!(!ic.has_duplicate_indices(),
TributaryError::DuplicatePinIndex);
}
if forward_config.is_native_output() {
require!(forward_config.output_mint == NATIVE_MINT,
TributaryError::NativeOutputRequiresWsol);
}
Ok(())
}
The per-ByteRangeCheck sanity loop (including the discriminator-pin
requirement for offset 0) lives in the handler body itself, iterating
forward_config.instruction_constraint.data_checks[i] — see
create_composable_policy.rs.
Validation specs (validate_spec_and_program + validate_init)¶
Validation is now handled per-slot (pre and post) through the ValidationSpec
enum. At create time the handler calls:
// Resolve each spec against the caller-supplied program account.
validate_spec_and_program(pre_validation,
ctx.accounts.pre_validation_program.key(),
ctx.accounts.system_program.key())?;
validate_spec_and_program(post_validation,
ctx.accounts.post_validation_program.key(),
ctx.accounts.system_program.key())?;
// Then validate the init data for each (assertion bytes, pin set).
validate_init(&pre_validation, &pre_init)?;
validate_init(&post_validation, &post_init)?;
Where validate_spec_and_program dispatches on the enum:
ValidationSpec::Disabled→ the passed program account must beSystemProgram(no CPI target needed). NoValidationPdais allocated.ValidationSpec::ProgramCall { program_id }→ the passed program must matchprogram_id, which must be inALLOWED_VALIDATION_PROGRAMS.ValidationSpec::Inline { .. }→ returnsInlineValidationNotImplemented.
And validate_init ensures:
- When
Disabled:validation_datamust be empty (no assertion to store). - When
ProgramCall:validation_datamust be non-empty and ≤ 1024 bytes, pins must be non-default, indices unique, within bounds.
The handler also re-pins the named input_mint / output_mint accounts
against the caller-supplied forward_config Pubkeys and runs the full
Token-2022 extension allowlist on both (validate_mint_compatible). Without
this, a policy could be created against a TransferHook /
PermanentDelegate / ConfidentialTransferMint mint that breaks
transfer_checked at execute time, or drains the PDA-owned intermediate ATA
in the PermanentDelegate case (see
reports/L-02-mint-validation-call-sites-incomplete.md).
Execute-time re-validation¶
The execute handler re-checks several invariants that were already enforced at create time. The pattern is defense-in-depth: these values are sourced from on-chain state, so a directly-serialized malformed account (or a regression in create-time validation) must not be able to trigger a panic or privilege escalation.
| Re-check | Source report |
|---|---|
validate_mint_compatible(input_mint) AND (output_mint) (Token-2022 extensions mutate) |
L-02 |
n <= checks.len() before indexing data_checks[i] |
H-04 |
ByteRangeCheck::validate rejects length > 8 rather than panicking |
H-06 |
pre_validation.program_id() == pre_program_info.key() (+ same for post) |
C-1 |
forward_config.target_program allowlist (transitively, via stored state) |
— |
Emergency pause¶
ProgramConfig.emergency_pause is a global kill switch. The execute
handler's config account carries:
#[account(
seeds = [CONFIG_SEED],
bump = config.bump,
constraint = !config.emergency_pause,
)]
pub config: Box<Account<'info, ProgramConfig>>,
When the pause flag is true, every execute_composable (and
execute_payment) call fails the constraint at the top of the handler,
before any state is read or any CPI is attempted. Create is also blocked
via the same flag on CreateComposablePolicy::config.
The flag is intended for incident response — e.g. an unresolved vulability in an allowlisted forward program. It does not freeze existing policy state; recipients and users retain full custody, and the flag can be cleared to resume execution.