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:
| Field | Type | Meaning |
|---|---|---|
start_fee_ppm | u32 | Initial swap fee, at most 990,000 ppm |
end_fee_ppm | u32 | Permanent floor, no greater than the initial fee |
starts_at | i64 | Unix timestamp at which decay begins |
duration_seconds | u64 | Positive 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 timestamp | Fee |
|---|---|
| Before or at 100 | 990,000 ppm = 99% |
| 125 | 743,250 ppm = 74.325% |
| 150 | 496,500 ppm = 49.65% |
| 175 | 249,750 ppm = 24.975% |
| At or after 200 | 3,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:
| Example | Configuration | Program contract |
|---|---|---|
| Dynamic fee | peak_ppm, floor_ppm, starts_at, ends_at | General schedule in the shared example policy format |
| Decaying launch fee | start_fee_ppm, end_fee_ppm, starts_at, duration_seconds; 99% convenience constructor | Dedicated 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.
Related Solana precedent
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.