Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

A ghost plan cannot inflate the pull amount against a subscriber

Source: subscriptions/tests/audit_high_ghost_plan.rs L164

  • When: the user initializes a subscription authority
  • When: the merchant creates plan 1
  • When: the subscriber subscribes
  • When: the owner updates the plan
  • When: the owner deletes the plan
  • When: the merchant creates plan 1
  • When: a puller transfers against the subscription
  • Then: the inflated ghost-plan pull is refused ✓

Result: ✗ failed — 1063 CU · claims 1/1 ✓

sequenceDiagram
    participant p0 as merchant
    participant p1 as Subscriptions
    p0->>p1: transferSubscription
    activate p1
    note over p1: 🚩 custom program error  0x207
    p1-->>p0: ✗ 1063cu
    deactivate p1

Authority

flowchart LR
    Subscriptions["Subscriptions"]:::program
    subscriptionplanmerchant1alice[("subscription(plan(merchant, 1), alice)")]:::state
    aliceATAMint[("alice/ATA(Mint)")]:::state
    merchantATAMint[("merchant/ATA(Mint)")]:::state
    merchant(["merchant"]):::signer
    Subscriptions -->|writes| subscriptionplanmerchant1alice
    Subscriptions -->|writes| aliceATAMint
    Subscriptions -->|writes| merchantATAMint
    merchant -->|signs| Subscriptions
    classDef program fill:#dae8fc,stroke:#6c8ebf;
    classDef signer fill:#d5e8d4,stroke:#82b366;
    classDef state fill:#ffe6cc,stroke:#d79b00;

Ownership

flowchart LR
    Subscriptions["Subscriptions"]:::program
    subscriptionplanmerchant1alice[("subscription(plan(merchant, 1), alice)")]:::state
    token["token"]:::program
    aliceATAMint[("alice/ATA(Mint)")]:::state
    merchantATAMint[("merchant/ATA(Mint)")]:::state
    system["system"]:::program
    merchant[("merchant")]:::state
    Subscriptions -->|owns| subscriptionplanmerchant1alice
    token -->|owns| aliceATAMint
    token -->|owns| merchantATAMint
    system -->|owns| merchant
    classDef program fill:#dae8fc,stroke:#6c8ebf;
    classDef signer fill:#d5e8d4,stroke:#82b366;
    classDef state fill:#ffe6cc,stroke:#d79b00;

Accounts

AccountSignerWritableOwner
subscription(plan(merchant, 1), alice)·Subscriptions
plan(merchant, 1)··Subscriptions
SA(alice, Mint)··Subscriptions
alice/ATA(Mint)·token
merchant/ATA(Mint)·token
merchantsystem
Mint··token
token··BPFL…
eventAuthority··system
Subscriptions··BPFL…
Call tree and logs
A ghost plan cannot inflate the pull amount against a subscriber
merchant (1063cu)
└─ Subscriptions::transferSubscription ✗ 1063cu
Program De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44 invoke [1]
Program De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44 consumed 1063 of 200000 compute units
Program De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44 failed: custom program error: 0x207

Integration

What an integrator writes: the derivations and the instruction, byte-identical to the transaction this page witnessed. Addresses are this scenario’s; supply your own.

#![allow(unused)]
fn main() {
use solana_instruction::{AccountMeta, Instruction};
use solana_pubkey::{pubkey, Pubkey};

// --- transferSubscription ---
let program_id: Pubkey = pubkey!("De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44");
let subscription_pda: Pubkey = pubkey!("6pQDdNgFksFRPMJQFvuiMheurG16kVi12So7UutPzWjH"); // subscription(plan(merchant, 1), alice): this scenario's value; supply your own
let plan_pda: Pubkey = pubkey!("2PQuEytmHE7C2a9ynpmsRfYK7UgXNtCqSR4qnNNgeZwp"); // plan(merchant, 1): this scenario's value; supply your own
let subscription_authority: Pubkey = pubkey!("4RDKDQRnKtZC5ti4wunSAayArwxdZida5St595CiKJ7i"); // SA(alice, Mint): this scenario's value; supply your own
let delegator_ata: Pubkey = pubkey!("3fVRopUs1BsF6RqteSmcyctBCx2YQ6Zkv7prsUz95VHY"); // supply your own
let receiver_ata: Pubkey = pubkey!("3FuYuMdLiEnmSsoGZEiXUDJSpiLgeKTmNTWUQkLAZyH1"); // supply your own
let caller: Pubkey = pubkey!("4jnW4cX63scWb3VGDVaacYunDWNFsGERr3YYUKPuNxJp"); // merchant: this scenario's value; supply your own
let token_mint: Pubkey = pubkey!("HhbhtPWNt5Rd2gvrjhGCekommdNQwVLY6ZMp3aBv4xtG"); // Mint: this scenario's value; supply your own
let token_program: Pubkey = pubkey!("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"); // the token program
let event_authority = Pubkey::find_program_address(&[b"event_authority"], &program_id).0;
let ix = Instruction {
    program_id,
    accounts: vec![
        AccountMeta::new(subscription_pda, false), // subscriptionPda
        AccountMeta::new_readonly(plan_pda, false), // planPda
        AccountMeta::new_readonly(subscription_authority, false), // subscriptionAuthority
        AccountMeta::new(delegator_ata, false), // delegatorAta
        AccountMeta::new(receiver_ata, false), // receiverAta
        AccountMeta::new_readonly(caller, true), // caller
        AccountMeta::new_readonly(token_mint, false), // tokenMint
        AccountMeta::new_readonly(token_program, false), // tokenProgram
        AccountMeta::new_readonly(event_authority, false), // eventAuthority
        AccountMeta::new_readonly(program_id, false), // selfProgram
    ],
    // discriminator ++ borsh(transferData: {amount: 50000000, delegator: 2rj1…, mint: Hhbh…})
    data: vec![0x0a, 0x80, 0xf0, 0xfa, 0x02, 0x00, 0x00, 0x00, 0x00, 0x1b, 0x98, 0xa3, 0xfc, 0x7a, 0x9f, 0x8b, 0x5c, 0x8b, 0x0e, 0xad, 0xe0, 0x77, 0x5a, 0x59, 0x3e, 0x44, 0x99, 0xa9, 0x76, 0x55, 0x97, 0xcd, 0x1b, 0xdc, 0xae, 0x63, 0xcd, 0x00, 0xf5, 0x19, 0xbc, 0xf8, 0x21, 0x76, 0xd4, 0x30, 0xe3, 0x1b, 0x7f, 0x8d, 0xe5, 0x16, 0xa8, 0xf6, 0xa3, 0x97, 0xc4, 0x57, 0x80, 0x12, 0x98, 0x73, 0x7a, 0xb3, 0x11, 0x69, 0xc0, 0x99, 0x8f, 0x1c, 0x02, 0x2b, 0xa1],
};
}

The same call through the Codama-generated TypeScript client (the async builder derives every PDA the resolver derived here, so only the free inputs appear):

import { address } from "@solana/kit";
import { getTransferSubscriptionInstructionAsync } from "your-generated-client";

const ix = await getTransferSubscriptionInstructionAsync({
  subscriptionPda: address("6pQDdNgFksFRPMJQFvuiMheurG16kVi12So7UutPzWjH"), // subscription(plan(merchant, 1), alice)
  planPda: address("2PQuEytmHE7C2a9ynpmsRfYK7UgXNtCqSR4qnNNgeZwp"), // plan(merchant, 1)
  subscriptionAuthority: address("4RDKDQRnKtZC5ti4wunSAayArwxdZida5St595CiKJ7i"), // SA(alice, Mint)
  delegatorAta: address("3fVRopUs1BsF6RqteSmcyctBCx2YQ6Zkv7prsUz95VHY"),
  receiverAta: address("3FuYuMdLiEnmSsoGZEiXUDJSpiLgeKTmNTWUQkLAZyH1"),
  caller: callerSigner, // merchant (a TransactionSigner)
  tokenMint: address("HhbhtPWNt5Rd2gvrjhGCekommdNQwVLY6ZMp3aBv4xtG"), // Mint
  // tokenProgram defaults to the token program
  transferData: {
    amount: 50000000n,
    delegator: address("2rj1EEgg32bupe12mMYnPFaKQCPPyYBhKYmDFZ8cXxpT") /* alice */,
    mint: address("HhbhtPWNt5Rd2gvrjhGCekommdNQwVLY6ZMp3aBv4xtG") /* Mint */
  },
});
How this page verifies itself: the test that ran it

The narrative above is this test; the page and the code cannot drift.

#![allow(unused)]
fn main() {
#[test]
fn finding_3_1_2_ghost_plan_inflated_amount_is_refused() {
    let mut story = Story::load(SO, IDL);
    // Alice consented to 1000/hour; the ghost plan sets 100M/hour.
    let g = stage_and_recreate(&mut story, 1_000, 100_000_000, 60);

    let alice_before = story.token_balance(&g.alice.ata(g.mint));
    // The attack: pull 50M, 50,000x what Alice agreed to.
    let plan_pda = story.plan_pda(&g.merchant.pubkey(), g.plan_id);
    let subscription_pda = story.subscription_pda(&plan_pda, &g.alice.pubkey());
    let pull = story.transfer_subscription(
        &g.merchant,
        &g.alice.pubkey(),
        g.mint,
        plan_pda,
        subscription_pda,
        &SubTransferArgs {
            amount: 50_000_000,
            to: None,
        },
    );

    story.then_holds("the inflated ghost-plan pull is refused", !pull.success);
    emit_scenario(
        &story,
        &pull,
        "A ghost plan cannot inflate the pull amount against a subscriber",
    );
    story.then(
        "refusal carries custom error code for PlanTermsMismatch",
        move |_s| refused_with(&pull, PLAN_TERMS_MISMATCH),
    );
    story.then(
        "Alice is not drained: the recreated terms mismatch her consent snapshot",
        move |s| s.token_balance(&g.alice.ata(g.mint)) == alice_before,
    );
}
}