Skip to content

Decay a launch fee from 99%

Configure an optional linear fee schedule with an explicit start, duration, and ending rate.

Example hook: decaying fee. examples/hooks/decaying-fee presents a 99%-to-floor preset of the existing linear dynamic-fee curve. It starts at a configured rate, decreases it with elapsed time, and keeps the selected ending rate afterward. Its convenience constructor starts at 990000 ppm, or 99%. The schedule lives in the external hook; the core supplies authenticated Clock and enforces the pool's fee cap.

Prerequisites: dynamic-fee replies, pool binding, and callback permissions.

1. Choose the schedule and pool cap

DecayingFeeSchedule contains:

FieldTypeMeaning
start_fee_ppmu32Initial swap fee, at most 990,000 ppm
end_fee_ppmu32Permanent floor, no greater than the initial fee
starts_ati64Unix timestamp at which decay begins
duration_secondsu64Positive duration of the decay

For example, start at 99% and reach 0.30% after 100 seconds:

use grapl_hook_decaying_fee::DecayingFeeSchedule;

let schedule = DecayingFeeSchedule::from_99_percent(3_000, 100, 100)
    .expect("valid positive duration and ending fee");
assert_eq!(schedule.fee_at(100, 990_000).unwrap(), 990_000);
assert_eq!(schedule.fee_at(150, 990_000).unwrap(), 496_500);
assert_eq!(schedule.fee_at(200, 990_000).unwrap(), 3_000);

The pool must have a cap at least as high as the initial fee. The core's maximum permitted configured cap is 990,000 ppm. Choosing this example does not raise a lower pool cap or change any default fee. A zero duration, rising schedule, initial rate above the supported ceiling, or schedule above the actual pool cap rejects.

A 99% fee is charged on consumed input under the AMM's fee calculation; it is not a guarantee about received output or price impact. Integer token rounding and user limits still apply. See fee accounting.

2. Follow exact time and rounding semantics

The evaluator uses call.clock.unix_timestamp, supplied by the authenticated core. Its integer calculation is:

elapsed = clamp(now − starts_at, 0, duration_seconds)
fee_ppm = start_fee_ppm
          − floor((start_fee_ppm − end_fee_ppm) × elapsed / duration_seconds)

Subtracting a rounded-down discount keeps the result at or just above the ideal fractional fee, with less than one ppm of rounding difference. The result is monotone, stays within the endpoints, and equals the ending rate when the duration has elapsed. Arithmetic uses checked integer widths.

For the example above:

Runtime timestampFee
Before or at 100990,000 ppm = 99%
125743,250 ppm = 74.325%
150496,500 ppm = 49.65%
175249,750 ppm = 24.975%
At or after 2003,000 ppm = 0.30%

Before the start, the hook allows an otherwise valid swap at the initial fee. There is no implicit trading-window gate. A launch that must prohibit early trading needs that additional rule composed explicitly by its hook author. Elapsed time determines this schedule; trade count and volume do not accelerate it.

3. Bind and configure the optional program

Select exactly BeforeSwap and the DynamicFee profile. The example uses one read-only PolicyPda { index: 0 }, derived as [b"policy", pool, &[0]] under its own program. Pass the matching descriptor with the core instruction; an empty payload is required.

configure_decaying_fee(schedule) produces the immutable setup bytes. The creator configures the policy after the pool exists. Setup accounts are creator (writable signer), pool (read-only), policy PDA (writable), and System Program (read-only), in that order. The state stores its version, pool binding, and schedule; decoding rejects wrong tags/versions, wrong length, and trailing bytes. Consult src/runtime/ and the fixture builders for executable setup and callback account checks.

The callback authenticates core origin, pool binding, enabled profile/phase, operation sequence, mints, and account descriptors before returning Some(fee_ppm). It never transfers tokens or changes core accounting itself. Subsequent core settlement still enforces output and price limits.

The two examples expose the same linear calculation and upward fee-rate rounding. Their configuration and program contracts differ:

ExampleConfigurationProgram contract
Dynamic feepeak_ppm, floor_ppm, starts_at, ends_atGeneral schedule in the shared example policy format
Decaying launch feestart_fee_ppm, end_fee_ppm, starts_at, duration_seconds; 99% convenience constructorDedicated immutable state, program binding, and adapter account plan

The pure fee calculation belongs in examples/hooks/shared/src/fee.rs; the preset's wrapper and account contract belong in examples/hooks/decaying-fee. Sharing that calculation prevents numerical drift. These are alternative external programs, and the core does not stack them automatically.

4. Preserve the schedule in quotes

A compatible Metis adapter must reuse the exact schedule and rounding at the same account/Clock snapshot. It must preserve the returned fee in native execution and reject unsupported phase/profile combinations. Public routing remains a separate integration step.

Time-based launch fee decay already exists in Solana products. Meteora's Fee Scheduler describes initial fees up to 99%, linear or exponential decay, and an ending base fee. The grapl example above implements its own linear external-hook contract; it does not implement Meteora's scheduler or an exponential curve.