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

subscriptions

Subscriptions is pull-payment plumbing: a wallet grants a bounded permission once, and payments then happen on schedule without a signature per charge. Every bound (how much, how often, until when) is enforced by the program, so the thing being granted is a cap, never a blank check.

The cast, in the order you will meet them:

  • a user establishes a subscription authority, the account that makes bounded pulls possible for their wallet at all;
  • a merchant publishes plans (price, period, terms);
  • a subscriber subscribes to a plan, which creates a delegation capped by that plan’s terms;
  • a puller (the merchant’s collector) transfers against the subscription each period, within the cap;
  • a delegator/delegatee pair can skip plans entirely and set up a direct, bounded delegation.

Every page in this guide is an executed test, not prose about one: the When lines are the steps, each Then is a claim the run verified (the ✓ is real), the sequence diagram shows who called whom, and the authority graph shows who signed for what, including the program signing through its PDA. The source link on each page points at the exact test that produced it. If the program’s behavior changes, these pages fail before they lie.

Getting started: your subscription authority

Before any money can move, a wallet opens its subscription authority: the program-owned account that will co-sign bounded pulls on the wallet’s behalf. This is the one-time setup step every other use case assumes. When you are done with subscriptions entirely, revoking kills every outstanding pull permission at once, and closing reclaims the account’s rent. Note the griefing case below: an attacker pre-funding your authority address cannot block you from initializing it.

A griefing pre-fund does not block subscription authority initialization

Source: subscriptions/tests/audit_high_3_1_3.rs L70

  • When: the user initializes a subscription authority
  • Then: the pre-funded PDA does not block creation (the fix holds) ✓
  • Then: the subscription authority decodes to a struct ✓

Result: ✓ success — 10077 CU · claims 2/2 ✓

sequenceDiagram
    participant p0 as alice
    participant p1 as Subscriptions
    participant p2 as system
    participant p3 as token
    p0->>p1: initSubscriptionAuthority
    activate p1
    p1->>p2: transferSol
    activate p2
    p2-->>p1: ✓
    deactivate p2
    p1->>p2: allocate
    activate p2
    p2-->>p1: ✓
    deactivate p2
    p1->>p2: assign
    activate p2
    p2-->>p1: ✓
    deactivate p2
    p1->>p3: approve
    activate p3
    p3-->>p1: ✓ 126cu
    deactivate p3
    p1-->>p0: ✓ 10077cu
    deactivate p1

Authority

flowchart LR
    Subscriptions["Subscriptions"]:::program
    alice(["alice"]):::signer
    SAaliceMint(["SA(alice, Mint)"]):::signer
    aliceATAMint[("alice/ATA(Mint)")]:::state
    system["system"]:::program
    token["token"]:::program
    alice -->|signs| Subscriptions
    Subscriptions -->|writes| SAaliceMint
    Subscriptions -->|writes| aliceATAMint
    alice -->|signs| system
    system -->|writes| SAaliceMint
    SAaliceMint -->|signs| system
    token -->|writes| aliceATAMint
    alice -->|signs| token
    classDef program fill:#dae8fc,stroke:#6c8ebf;
    classDef signer fill:#d5e8d4,stroke:#82b366;
    classDef state fill:#ffe6cc,stroke:#d79b00;

Ownership

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

Accounts

AccountSignerWritableOwner
alicesystem
SA(alice, Mint)·Subscriptions
Mint··token
alice/ATA(Mint)·token
system··Nati…
token··BPFL…
Call tree and logs
A griefing pre-fund does not block subscription authority initialization
alice (10077cu)
└─ Subscriptions::initSubscriptionAuthority ✓ 10077cu
   ├─ system::transferSol ✓
   ├─ system::allocate ✓
   ├─ system::assign ✓
   └─ token::approve ✓ 126cu
Program De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44 invoke [1]
Program 11111111111111111111111111111111 invoke [2]
Program 11111111111111111111111111111111 success
Program 11111111111111111111111111111111 invoke [2]
Program 11111111111111111111111111111111 success
Program 11111111111111111111111111111111 invoke [2]
Program 11111111111111111111111111111111 success
Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [2]
Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 126 of 190066 compute units
Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success
Program De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44 consumed 10077 of 200000 compute units
Program De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44 success

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};

// --- initSubscriptionAuthority ---
let program_id: Pubkey = pubkey!("De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44");
let owner: Pubkey = pubkey!("2rj1EEgg32bupe12mMYnPFaKQCPPyYBhKYmDFZ8cXxpT"); // alice: this scenario's value; supply your own
let token_mint: Pubkey = pubkey!("HhbhtPWNt5Rd2gvrjhGCekommdNQwVLY6ZMp3aBv4xtG"); // Mint: this scenario's value; supply your own
let user_ata: Pubkey = pubkey!("3fVRopUs1BsF6RqteSmcyctBCx2YQ6Zkv7prsUz95VHY"); // supply your own
let system_program: Pubkey = pubkey!("11111111111111111111111111111111"); // the system program
let token_program: Pubkey = pubkey!("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"); // the token program
let subscription_authority = Pubkey::find_program_address(&[b"SubscriptionAuthority", owner.as_ref(), token_mint.as_ref()], &program_id).0;
let ix = Instruction {
    program_id,
    accounts: vec![
        AccountMeta::new(owner, true), // owner
        AccountMeta::new(subscription_authority, false), // subscriptionAuthority
        AccountMeta::new_readonly(token_mint, false), // tokenMint
        AccountMeta::new(user_ata, false), // userAta
        AccountMeta::new_readonly(system_program, false), // systemProgram
        AccountMeta::new_readonly(token_program, false), // tokenProgram
    ],
    // discriminator ++ borsh()
    data: vec![0x00],
};
}

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 { getInitSubscriptionAuthorityInstructionAsync } from "your-generated-client";

const ix = await getInitSubscriptionAuthorityInstructionAsync({
  owner: ownerSigner, // alice (a TransactionSigner)
  tokenMint: address("HhbhtPWNt5Rd2gvrjhGCekommdNQwVLY6ZMp3aBv4xtG"), // Mint
  userAta: address("3fVRopUs1BsF6RqteSmcyctBCx2YQ6Zkv7prsUz95VHY"),
  // systemProgram defaults to the system program
  // tokenProgram defaults to the token program
});
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_3_prefunded_pda_does_not_block_creation() {
    let mut story = Story::load(SO, IDL);
    let alice = story.cast("alice");
    let _mallory = story.cast("mallory"); // the griefer is off-chain; the pre-fund is the attack
    let mint = story.mint(&alice, MINT_DECIMALS);
    story.fund(&alice, &[(mint, 1_000_000)]);

    let pda = story.subscription_authority_pda(&alice.pubkey(), &mint);

    // The attack: pre-fund Alice's authority PDA address with lamports, no data,
    // owned by the System program (Pubkey::default() is the all-zero System id).
    story.set_account(&pda, Pubkey::default(), 1_000_000, vec![]);

    // On an unconditional CreateAccount this is rejected by the System program;
    // the fixed program tops up the rent and Allocate/Assign-s in place.
    let outcome = story.init_authority(&alice, mint);

    story.then_holds(
        "the pre-funded PDA does not block creation (the fix holds)",
        outcome.success,
    );

    // The authority account now exists and decodes against its named type.
    story.then("the subscription authority decodes to a struct", move |s| {
        matches!(
            s.account_as("subscriptionAuthority", &pda),
            Value::Struct(_)
        )
    });
    emit_scenario(
        &story,
        &outcome,
        "A griefing pre-fund does not block subscription authority initialization",
    );
}
}

A user initializes a subscription authority

Source: subscriptions/tests/test_initialize_subscription_authority.rs L92

  • When: the user initializes a subscription authority
  • Then: initSubscriptionAuthority executes source-free ✓
  • Then: subscriptionAuthority.user = 2rj1EEgg32bupe12mMYnPFaKQCPPyYBhKYmDFZ8cXxpT ✓
  • Then: subscriptionAuthority.tokenMint = HhbhtPWNt5Rd2gvrjhGCekommdNQwVLY6ZMp3aBv4xtG ✓
  • Then: subscriptionAuthority.payer = 2rj1EEgg32bupe12mMYnPFaKQCPPyYBhKYmDFZ8cXxpT ✓

Result: ✓ success — 7801 CU · claims 4/4 ✓

sequenceDiagram
    participant p0 as alice
    participant p1 as Subscriptions
    participant p2 as system
    participant p3 as token
    p0->>p1: initSubscriptionAuthority
    activate p1
    p1->>p2: createAccount
    activate p2
    p2-->>p1: ✓
    deactivate p2
    p1->>p3: approve
    activate p3
    p3-->>p1: ✓ 126cu
    deactivate p3
    p1-->>p0: ✓ 7801cu
    deactivate p1

Authority

flowchart LR
    Subscriptions["Subscriptions"]:::program
    alice(["alice"]):::signer
    SAaliceMint(["SA(alice, Mint)"]):::signer
    aliceATAMint[("alice/ATA(Mint)")]:::state
    system["system"]:::program
    token["token"]:::program
    alice -->|signs| Subscriptions
    Subscriptions -->|writes| SAaliceMint
    Subscriptions -->|writes| aliceATAMint
    alice -->|signs| system
    SAaliceMint -->|signs| system
    token -->|writes| aliceATAMint
    alice -->|signs| token
    classDef program fill:#dae8fc,stroke:#6c8ebf;
    classDef signer fill:#d5e8d4,stroke:#82b366;
    classDef state fill:#ffe6cc,stroke:#d79b00;

Ownership

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

Accounts

AccountSignerWritableOwner
alicesystem
SA(alice, Mint)·Subscriptions
Mint··token
alice/ATA(Mint)·token
system··Nati…
token··BPFL…
Call tree and logs
A user initializes a subscription authority
alice (7801cu)
└─ Subscriptions::initSubscriptionAuthority ✓ 7801cu
   ├─ system::createAccount ✓
   └─ token::approve ✓ 126cu
Program De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44 invoke [1]
Program 11111111111111111111111111111111 invoke [2]
Program 11111111111111111111111111111111 success
Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [2]
Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 126 of 192342 compute units
Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success
Program De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44 consumed 7801 of 200000 compute units
Program De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44 success

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};

// --- initSubscriptionAuthority ---
let program_id: Pubkey = pubkey!("De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44");
let owner: Pubkey = pubkey!("2rj1EEgg32bupe12mMYnPFaKQCPPyYBhKYmDFZ8cXxpT"); // alice: this scenario's value; supply your own
let token_mint: Pubkey = pubkey!("HhbhtPWNt5Rd2gvrjhGCekommdNQwVLY6ZMp3aBv4xtG"); // Mint: this scenario's value; supply your own
let user_ata: Pubkey = pubkey!("3fVRopUs1BsF6RqteSmcyctBCx2YQ6Zkv7prsUz95VHY"); // supply your own
let system_program: Pubkey = pubkey!("11111111111111111111111111111111"); // the system program
let token_program: Pubkey = pubkey!("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"); // the token program
let subscription_authority = Pubkey::find_program_address(&[b"SubscriptionAuthority", owner.as_ref(), token_mint.as_ref()], &program_id).0;
let ix = Instruction {
    program_id,
    accounts: vec![
        AccountMeta::new(owner, true), // owner
        AccountMeta::new(subscription_authority, false), // subscriptionAuthority
        AccountMeta::new_readonly(token_mint, false), // tokenMint
        AccountMeta::new(user_ata, false), // userAta
        AccountMeta::new_readonly(system_program, false), // systemProgram
        AccountMeta::new_readonly(token_program, false), // tokenProgram
    ],
    // discriminator ++ borsh()
    data: vec![0x00],
};
}

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 { getInitSubscriptionAuthorityInstructionAsync } from "your-generated-client";

const ix = await getInitSubscriptionAuthorityInstructionAsync({
  owner: ownerSigner, // alice (a TransactionSigner)
  tokenMint: address("HhbhtPWNt5Rd2gvrjhGCekommdNQwVLY6ZMp3aBv4xtG"), // Mint
  userAta: address("3fVRopUs1BsF6RqteSmcyctBCx2YQ6Zkv7prsUz95VHY"),
  // systemProgram defaults to the system program
  // tokenProgram defaults to the token program
});
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 initialize_subscription_authority() {
    let mut story = Story::load(SO, IDL);
    let alice = story.cast("alice");
    let mint = story.mint(&alice, MINT_DECIMALS);
    story.fund(&alice, &[(mint, 1_000_000)]);
    let user_ata = story.ata(&alice.pubkey(), &mint);

    let outcome = story.init_subscription_authority(InitAuthorityArgs {
        user: &alice,
        mint,
        sponsor: None,
        token_program: None,
    });
    story.then_holds(
        "initSubscriptionAuthority executes source-free",
        outcome.success,
    );

    let pda = story.subscription_authority_pda(&alice.pubkey(), &mint);
    let user = story.track_field::<Pubkey>("subscriptionAuthority", pda, "user");
    let token_mint = story.track_field::<Pubkey>("subscriptionAuthority", pda, "tokenMint");
    let payer = story.track_field::<Pubkey>("subscriptionAuthority", pda, "payer");
    let _init_id = story.track_field::<i64>("subscriptionAuthority", pda, "initId");

    story.then_eq(&user, alice.pubkey());
    story.then_eq(&token_mint, mint);
    // With no sponsor, the payer defaults to the user.
    story.then_eq(&payer, alice.pubkey());
    // The witness validates the init_id field exists and is an i64

    // The instruction's Approve CPI delegates the ATA to the authority in full.
    assert_eq!(
        story.ata_delegate(&user_ata),
        Some(pda),
        "the ATA is delegated to the authority"
    );
    assert_eq!(
        story.ata_delegated_amount(&user_ata),
        u64::MAX,
        "the delegated amount is u64::MAX"
    );
    emit_scenario(
        &story,
        &outcome,
        "A user initializes a subscription authority",
    );
}
}

A user revokes their subscription authority

Source: subscriptions/tests/test_revoke_subscription_authority.rs L92

  • When: the user initializes a subscription authority
  • Then: the ATA is delegated before revoke ✓
  • Then: the ATA is delegated for the full amount ✓
  • When: the user revokes the subscription authority
  • Then: Alice revokes her subscription authority ✓
  • Then: the delegate is cleared after revoke ✓
  • Then: the delegated amount is zeroed after revoke ✓

Result: ✓ success — 7978 CU · claims 5/5 ✓

sequenceDiagram
    participant p0 as alice
    participant p1 as Subscriptions
    participant p2 as token
    p0->>p1: revokeSubscriptionAuthority
    activate p1
    p1->>p2: revoke
    activate p2
    p2-->>p1: ✓ 108cu
    deactivate p2
    p1-->>p0: ✓ 7978cu
    deactivate p1

Authority

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

Ownership

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

Accounts

AccountSignerWritableOwner
alicesystem
alice/ATA(Mint)·token
Mint··token
token··BPFL…
SA(alice, Mint)·system
Call tree and logs
A user revokes their subscription authority
alice (7978cu)
└─ Subscriptions::revokeSubscriptionAuthority ✓ 7978cu
   └─ token::revoke ✓ 108cu
Program De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44 invoke [1]
Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [2]
Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 108 of 193853 compute units
Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success
Program De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44 consumed 7978 of 200000 compute units
Program De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44 success

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};

// --- revokeSubscriptionAuthority ---
let program_id: Pubkey = pubkey!("De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44");
let user: Pubkey = pubkey!("2rj1EEgg32bupe12mMYnPFaKQCPPyYBhKYmDFZ8cXxpT"); // alice: this scenario's value; supply your own
let user_ata: Pubkey = pubkey!("3fVRopUs1BsF6RqteSmcyctBCx2YQ6Zkv7prsUz95VHY"); // 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 subscription_authority: Pubkey = pubkey!("4RDKDQRnKtZC5ti4wunSAayArwxdZida5St595CiKJ7i"); // SA(alice, Mint): this scenario's value; supply your own
let ix = Instruction {
    program_id,
    accounts: vec![
        AccountMeta::new(user, true), // user
        AccountMeta::new(user_ata, false), // userAta
        AccountMeta::new_readonly(token_mint, false), // tokenMint
        AccountMeta::new_readonly(token_program, false), // tokenProgram
        AccountMeta::new(subscription_authority, false), // subscriptionAuthority
    ],
    // discriminator ++ borsh()
    data: vec![0x0e],
};
}

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 { getRevokeSubscriptionAuthorityInstructionAsync } from "your-generated-client";

const ix = await getRevokeSubscriptionAuthorityInstructionAsync({
  user: userSigner, // alice (a TransactionSigner)
  userAta: address("3fVRopUs1BsF6RqteSmcyctBCx2YQ6Zkv7prsUz95VHY"),
  tokenMint: address("HhbhtPWNt5Rd2gvrjhGCekommdNQwVLY6ZMp3aBv4xtG"), // Mint
  // tokenProgram defaults to the token program
  subscriptionAuthority: address("4RDKDQRnKtZC5ti4wunSAayArwxdZida5St595CiKJ7i"), // SA(alice, 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 revoke_subscription_authority_clears_delegate() {
    let mut story = Story::load(SO, IDL);
    let alice = story.cast("alice");
    let mint = story.mint(&alice, MINT_DECIMALS);
    story.fund(&alice, &[(mint, 1_000_000)]);
    let user_ata = story.ata(&alice.pubkey(), &mint);

    story
        .init_subscription_authority(InitAuthorityArgs {
            user: &alice,
            mint,
            sponsor: None,
            token_program: None,
        })
        .expect_success();
    story.then_holds(
        "the ATA is delegated before revoke",
        story.ata_delegate(&user_ata).is_some(),
    );
    story.then_holds(
        "the ATA is delegated for the full amount",
        story.ata_delegated_amount(&user_ata) == u64::MAX,
    );

    let outcome = story.revoke_subscription_authority(RevokeAuthorityArgs {
        user: &alice,
        mint,
        ata: None,
    });
    story.then_holds("Alice revokes her subscription authority", outcome.success);

    story.then_holds(
        "the delegate is cleared after revoke",
        story.ata_delegate(&user_ata).is_none(),
    );
    story.then_holds(
        "the delegated amount is zeroed after revoke",
        story.ata_delegated_amount(&user_ata) == 0,
    );
    emit_scenario(
        &story,
        &outcome,
        "A user revokes their subscription authority",
    );
}
}

A user closes their subscription authority

Source: subscriptions/tests/test_close_subscription_authority.rs L98

  • When: the user initializes a subscription authority
  • When: the user closes the subscription authority
  • Then: Alice closes her own authority ✓
  • Then: the authority account is gone or emptied ✓
  • Then: Alice’s balance grew ✓
  • Then: Alice recovers the rent (less fees) ✓

Result: ✓ success — 1832 CU · claims 4/4 ✓

sequenceDiagram
    participant p0 as alice
    participant p1 as Subscriptions
    p0->>p1: closeSubscriptionAuthority
    activate p1
    p1-->>p0: ✓ 1832cu
    deactivate p1

Authority

flowchart LR
    Subscriptions["Subscriptions"]:::program
    alice(["alice"]):::signer
    SAaliceMint[("SA(alice, Mint)")]:::state
    alice -->|signs| Subscriptions
    Subscriptions -->|writes| SAaliceMint
    classDef program fill:#dae8fc,stroke:#6c8ebf;
    classDef signer fill:#d5e8d4,stroke:#82b366;
    classDef state fill:#ffe6cc,stroke:#d79b00;

Ownership

flowchart LR
    system["system"]:::program
    alice[("alice")]:::state
    SAaliceMint[("SA(alice, Mint)")]:::state
    system -->|owns| alice
    system -->|owns| SAaliceMint
    classDef program fill:#dae8fc,stroke:#6c8ebf;
    classDef signer fill:#d5e8d4,stroke:#82b366;
    classDef state fill:#ffe6cc,stroke:#d79b00;

Accounts

AccountSignerWritableOwner
alicesystem
SA(alice, Mint)·system
Call tree and logs
A user closes their subscription authority
alice (1832cu)
└─ Subscriptions::closeSubscriptionAuthority ✓ 1832cu
Program De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44 invoke [1]
Program De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44 consumed 1832 of 200000 compute units
Program De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44 success

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};

// --- closeSubscriptionAuthority ---
let program_id: Pubkey = pubkey!("De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44");
let user: Pubkey = pubkey!("2rj1EEgg32bupe12mMYnPFaKQCPPyYBhKYmDFZ8cXxpT"); // alice: this scenario's value; supply your own
let subscription_authority: Pubkey = pubkey!("4RDKDQRnKtZC5ti4wunSAayArwxdZida5St595CiKJ7i"); // SA(alice, Mint): this scenario's value; supply your own
let ix = Instruction {
    program_id,
    accounts: vec![
        AccountMeta::new(user, true), // user
        AccountMeta::new(subscription_authority, false), // subscriptionAuthority
    ],
    // discriminator ++ borsh()
    data: vec![0x06],
};
}

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 { getCloseSubscriptionAuthorityInstructionAsync } from "your-generated-client";

const ix = await getCloseSubscriptionAuthorityInstructionAsync({
  user: userSigner, // alice (a TransactionSigner)
  subscriptionAuthority: address("4RDKDQRnKtZC5ti4wunSAayArwxdZida5St595CiKJ7i"), // SA(alice, 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 close_subscription_authority() {
    let mut story = Story::load(SO, IDL);
    let alice = story.cast("alice");
    let mint = story.mint(&alice, MINT_DECIMALS);
    story.fund(&alice, &[(mint, 1_000_000)]);
    story
        .init_subscription_authority(InitAuthorityArgs {
            user: &alice,
            mint,
            sponsor: None,
            token_program: None,
        })
        .expect_success();

    let pda = story.subscription_authority_pda(&alice.pubkey(), &mint);
    let rent = story
        .svm
        .get_account(&pda)
        .expect("the authority exists before close")
        .lamports;
    let alice_before = story.svm.get_account(&alice.pubkey()).unwrap().lamports;

    let outcome = story.close_subscription_authority(CloseAuthorityArgs {
        user: &alice,
        mint,
        receiver: None,
        authority: None,
    });
    story.then_holds("Alice closes her own authority", outcome.success);

    let after = story.svm.get_account(&pda);
    story.then_holds(
        "the authority account is gone or emptied",
        after.is_none() || after.as_ref().map(|a| a.lamports).unwrap_or(0) == 0,
    );

    let alice_after = story.svm.get_account(&alice.pubkey()).unwrap().lamports;
    story.then_holds("Alice's balance grew", alice_after > alice_before);
    story.then_holds(
        "Alice recovers the rent (less fees)",
        alice_after >= alice_before + rent - 10_000,
    );
    emit_scenario(
        &story,
        &outcome,
        "A user closes their subscription authority",
    );
}
}

Selling subscriptions: plans

A merchant’s product is a plan: the published terms (amount, period) that subscribers opt into. Plans are the merchant’s to manage: create one per offering, update terms as pricing evolves, and delete a retired plan. Subscribers’ existing delegations are always bounded by the terms they agreed to, which is what the ghost-plan cases under Collecting payments verify from the other side.

A merchant creates a subscription plan

Source: subscriptions/tests/create_plan.rs L52

  • When: the merchant creates plan 1
  • Then: createPlan executes source-free ✓
  • Then: plan.owner = 4jnW4cX63scWb3VGDVaacYunDWNFsGERr3YYUKPuNxJp ✓
  • Then: plan.data.planId = 1 ✓
  • Then: plan.data.destinations[0] = 11157t3sqMV725NVRLrVQbAu98Jjfk1uCKehJnXXQs ✓

Result: ✓ success — 3527 CU · claims 4/4 ✓

sequenceDiagram
    participant p0 as merchant
    participant p1 as Subscriptions
    participant p2 as system
    p0->>p1: createPlan
    activate p1
    p1->>p2: createAccount
    activate p2
    p2-->>p1: ✓
    deactivate p2
    p1-->>p0: ✓ 3527cu
    deactivate p1

Authority

flowchart LR
    Subscriptions["Subscriptions"]:::program
    merchant(["merchant"]):::signer
    planmerchant1(["plan(merchant, 1)"]):::signer
    system["system"]:::program
    merchant -->|signs| Subscriptions
    Subscriptions -->|writes| planmerchant1
    merchant -->|signs| system
    planmerchant1 -->|signs| system
    classDef program fill:#dae8fc,stroke:#6c8ebf;
    classDef signer fill:#d5e8d4,stroke:#82b366;
    classDef state fill:#ffe6cc,stroke:#d79b00;

Ownership

flowchart LR
    system["system"]:::program
    merchant[("merchant")]:::state
    Subscriptions["Subscriptions"]:::program
    planmerchant1[("plan(merchant, 1)")]:::state
    system -->|owns| merchant
    Subscriptions -->|owns| planmerchant1
    classDef program fill:#dae8fc,stroke:#6c8ebf;
    classDef signer fill:#d5e8d4,stroke:#82b366;
    classDef state fill:#ffe6cc,stroke:#d79b00;

Accounts

AccountSignerWritableOwner
merchantsystem
plan(merchant, 1)·Subscriptions
Mint··token
system··Nati…
token··BPFL…
Call tree and logs
A merchant creates a subscription plan
merchant (3527cu)
└─ Subscriptions::createPlan ✓ 3527cu
   └─ system::createAccount ✓
Program De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44 invoke [1]
Program 11111111111111111111111111111111 invoke [2]
Program 11111111111111111111111111111111 success
Program De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44 consumed 3527 of 200000 compute units
Program De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44 success

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};

// --- createPlan ---
let program_id: Pubkey = pubkey!("De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44");
let merchant: Pubkey = pubkey!("4jnW4cX63scWb3VGDVaacYunDWNFsGERr3YYUKPuNxJp"); // merchant: this scenario's value; supply your own
let plan_pda: Pubkey = pubkey!("2PQuEytmHE7C2a9ynpmsRfYK7UgXNtCqSR4qnNNgeZwp"); // plan(merchant, 1): this scenario's value; supply your own
let token_mint: Pubkey = pubkey!("AR8UqHZKa2XpUwoXpU7NMqwLQ2xkRHQYY13BzYv9yTys"); // Mint: this scenario's value; supply your own
let system_program: Pubkey = pubkey!("11111111111111111111111111111111"); // the system program
let token_program: Pubkey = pubkey!("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"); // the token program
let ix = Instruction {
    program_id,
    accounts: vec![
        AccountMeta::new(merchant, true), // merchant
        AccountMeta::new(plan_pda, false), // planPda
        AccountMeta::new_readonly(token_mint, false), // tokenMint
        AccountMeta::new_readonly(system_program, false), // systemProgram
        AccountMeta::new_readonly(token_program, false), // tokenProgram
    ],
    // discriminator ++ borsh(planData: {planId: 1, mint: AR8U…, terms: {amount: 1000000, periodHours: 720, createdAt: 0}, endTs: 2592000, destinations: [1115…, 1111…, 1111…, 1111…], pullers: [1117…, 1111…, 1111…, 1111…], metadataUri: "https://example.com/plan.json"})
    data: vec![0x07, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x8b, 0xe7, 0x94, 0x4b, 0x29, 0x7c, 0xe4, 0x02, 0x4f, 0x56, 0x53, 0x32, 0x66, 0xdb, 0xde, 0x75, 0xd0, 0x23, 0x34, 0xbc, 0x99, 0x97, 0x0e, 0xaf, 0x8f, 0x75, 0xa2, 0x1f, 0x27, 0xd2, 0x9c, 0x2a, 0x40, 0x42, 0x0f, 0x00, 0x00, 0x00, 0x00, 0x00, 0xd0, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x8d, 0x27, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x90, 0x70, 0x7b, 0xc3, 0xef, 0x25, 0xbd, 0xc9, 0x8e, 0xd7, 0x5c, 0xb7, 0x0d, 0x61, 0xc8, 0xb1, 0x06, 0xdc, 0x24, 0x8d, 0x8e, 0xf6, 0x1e, 0x1d, 0x1d, 0xb1, 0xca, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x91, 0xfa, 0xec, 0x69, 0xd8, 0x5e, 0x2a, 0x17, 0x4f, 0x6b, 0x38, 0xa2, 0x14, 0x11, 0x1e, 0x3e, 0x1d, 0x26, 0x5f, 0x00, 0xa9, 0x9e, 0xe2, 0x71, 0x2e, 0x17, 0x80, 0xe5, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x68, 0x74, 0x74, 0x70, 0x73, 0x3a, 0x2f, 0x2f, 0x65, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x70, 0x6c, 0x61, 0x6e, 0x2e, 0x6a, 0x73, 0x6f, 0x6e, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00],
};
}

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 { getCreatePlanInstructionAsync } from "your-generated-client";

const ix = await getCreatePlanInstructionAsync({
  merchant: merchantSigner, // merchant (a TransactionSigner)
  planPda: address("2PQuEytmHE7C2a9ynpmsRfYK7UgXNtCqSR4qnNNgeZwp"), // plan(merchant, 1)
  tokenMint: address("AR8UqHZKa2XpUwoXpU7NMqwLQ2xkRHQYY13BzYv9yTys"), // Mint
  // systemProgram defaults to the system program
  // tokenProgram defaults to the token program
  planData: {
    planId: 1n,
    mint: address("AR8UqHZKa2XpUwoXpU7NMqwLQ2xkRHQYY13BzYv9yTys") /* Mint */,
    terms: {
      amount: 1000000n,
      periodHours: 720n,
      createdAt: 0n
    },
    endTs: 2592000n,
    destinations: [address("11157t3sqMV725NVRLrVQbAu98Jjfk1uCKehJnXXQs"), address("11111111111111111111111111111111"), address("11111111111111111111111111111111"), address("11111111111111111111111111111111")],
    pullers: [address("1117mWrzzrZr312ebPDHu8tbfMwFNvCvMbr6WepCNG"), address("11111111111111111111111111111111"), address("11111111111111111111111111111111"), address("11111111111111111111111111111111")],
    metadataUri: "https://example.com/plan.json"
  },
});
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 create_plan_happy_path() {
    let mut story = Story::load(SO, IDL);
    let merchant = story.cast("merchant");
    let mint = story.mint(&merchant, MINT_DECIMALS);
    let dest = Pubkey::new_unique();
    let puller = Pubkey::new_unique();

    let outcome = story.create_plan(
        &merchant,
        mint,
        &PlanArgs {
            plan_id: 1,
            amount: 1_000_000,
            period_hours: 720,
            end_ts: 30 * 86_400,
            destinations: vec![dest],
            pullers: vec![puller],
            metadata_uri: "https://example.com/plan.json".into(),
        },
    );
    story.then_holds("createPlan executes source-free", outcome.success);

    // The plan account decodes against its named type; its fields round-trip,
    // read through typed field probes (a broken decode or path fails the
    // OBSERVATION, reported by the interrupted-arc guard; a wrong value fails
    // the claim with got/want).
    let plan_pda = story.plan_pda(&merchant.pubkey(), 1);
    let owner = story.track_field::<Pubkey>("plan", plan_pda, "owner");
    let plan_id = story.track_field::<u64>("plan", plan_pda, "data.planId");
    // The first destination round-trips (the rest are padded default keys).
    let first_dest = story.track_field::<Pubkey>("plan", plan_pda, "data.destinations[0]");

    story.then_eq(&owner, merchant.pubkey());
    story.then_eq(&plan_id, 1);
    story.then_eq(&first_dest, dest);
    emit_scenario(&story, &outcome, "A merchant creates a subscription plan");
}
}

A merchant updates a plan

Source: subscriptions/tests/test_update_plan.rs L126

  • When: the merchant creates plan 1
  • Then: staging createPlan should succeed ✓
  • When: the owner updates the plan
  • Then: the merchant sunsets its own plan ✓
  • Then: plan.status = 0 ✓
  • Then: plan.data.endTs = 5184000 ✓
  • Then: plan.data.metadataUri = “https://example.com/updated.json” ✓

Result: ✓ success — 1949 CU · claims 5/5 ✓

sequenceDiagram
    participant p0 as merchant
    participant p1 as Subscriptions
    p0->>p1: updatePlan
    activate p1
    p1->>p1: emit:planUpdatedEvent
    activate p1
    p1-->>p1: ✓ 137cu
    deactivate p1
    p1-->>p0: ✓ 1949cu
    deactivate p1

Authority

flowchart LR
    Subscriptions["Subscriptions"]:::program
    merchant(["merchant"]):::signer
    planmerchant1[("plan(merchant, 1)")]:::state
    eventAuthority(["eventAuthority"]):::signer
    merchant -->|signs| Subscriptions
    Subscriptions -->|writes| planmerchant1
    eventAuthority -->|signs| Subscriptions
    classDef program fill:#dae8fc,stroke:#6c8ebf;
    classDef signer fill:#d5e8d4,stroke:#82b366;
    classDef state fill:#ffe6cc,stroke:#d79b00;

Ownership

flowchart LR
    system["system"]:::program
    merchant[("merchant")]:::state
    Subscriptions["Subscriptions"]:::program
    planmerchant1[("plan(merchant, 1)")]:::state
    system -->|owns| merchant
    Subscriptions -->|owns| planmerchant1
    classDef program fill:#dae8fc,stroke:#6c8ebf;
    classDef signer fill:#d5e8d4,stroke:#82b366;
    classDef state fill:#ffe6cc,stroke:#d79b00;

Accounts

AccountSignerWritableOwner
merchantsystem
plan(merchant, 1)·Subscriptions
eventAuthority··system
Subscriptions··BPFL…
Call tree and logs
A merchant updates a plan
merchant (1949cu)
└─ Subscriptions::updatePlan ✓ 1949cu
   └─ Subscriptions::emit:planUpdatedEvent ✓ 137cu
Program De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44 invoke [1]
Program De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44 invoke [2]
Program De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44 consumed 137 of 198212 compute units
Program De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44 success
Program De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44 consumed 1949 of 200000 compute units
Program De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44 success

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};

// --- updatePlan ---
let program_id: Pubkey = pubkey!("De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44");
let owner: Pubkey = pubkey!("4jnW4cX63scWb3VGDVaacYunDWNFsGERr3YYUKPuNxJp"); // merchant: this scenario's value; supply your own
let plan_pda: Pubkey = pubkey!("2PQuEytmHE7C2a9ynpmsRfYK7UgXNtCqSR4qnNNgeZwp"); // plan(merchant, 1): this scenario's value; supply your own
let event_authority = Pubkey::find_program_address(&[b"event_authority"], &program_id).0;
let ix = Instruction {
    program_id,
    accounts: vec![
        AccountMeta::new_readonly(owner, true), // owner
        AccountMeta::new(plan_pda, false), // planPda
        AccountMeta::new_readonly(event_authority, false), // eventAuthority
        AccountMeta::new_readonly(program_id, false), // selfProgram
    ],
    // discriminator ++ borsh(updatePlanData: {status: 0, endTs: 5184000, pullers: [1111…, 1111…, 1111…, 1111…], metadataUri: "https://example.com/updated.json"})
    data: vec![0x08, 0x00, 0x00, 0x1a, 0x4f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x68, 0x74, 0x74, 0x70, 0x73, 0x3a, 0x2f, 0x2f, 0x65, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x2e, 0x6a, 0x73, 0x6f, 0x6e, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00],
};
}

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 { getUpdatePlanInstructionAsync } from "your-generated-client";

const ix = await getUpdatePlanInstructionAsync({
  owner: ownerSigner, // merchant (a TransactionSigner)
  planPda: address("2PQuEytmHE7C2a9ynpmsRfYK7UgXNtCqSR4qnNNgeZwp"), // plan(merchant, 1)
  updatePlanData: {
    status: 0,
    endTs: 5184000n,
    pullers: [address("11111111111111111111111111111111"), address("11111111111111111111111111111111"), address("11111111111111111111111111111111"), address("11111111111111111111111111111111")],
    metadataUri: "https://example.com/updated.json"
  },
});
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 update_plan_happy_path() {
    let mut story = Story::load(SO, IDL);
    let plan_pda = stage_plan(&mut story, 1_000_000, 720, 0, vec![]);
    let owner = merchant(&mut story);

    let end_ts = story.now() + days(60);
    let outcome = story.update_plan(
        &owner,
        plan_pda,
        UpdatePlanArgs {
            status: STATUS_SUNSET,
            end_ts,
            metadata_uri: "https://example.com/updated.json".into(),
            ..Default::default()
        },
    );
    story.then_holds("the merchant sunsets its own plan", outcome.success);

    let status = story.track_field::<u8>("plan", plan_pda, "status");
    let end_ts_field = story.track_field::<i64>("plan", plan_pda, "data.endTs");
    let metadata_uri = story.track_field::<String>("plan", plan_pda, "data.metadataUri");

    story.then_eq(&status, STATUS_SUNSET);
    story.then_eq(&end_ts_field, end_ts);
    story.then_eq(
        &metadata_uri,
        "https://example.com/updated.json".to_string(),
    );
    emit_scenario(&story, &outcome, "A merchant updates a plan");
}
}

A merchant deletes a plan

Source: subscriptions/tests/test_delete_plan.rs L118

  • When: the merchant creates plan 1
  • When: the owner updates the plan
  • Then: the merchant sunsets the plan ✓
  • Then: the plan account holds rent before deletion ✓
  • When: the owner deletes the plan
  • Then: the merchant deletes the expired plan ✓
  • Then: the plan account is drained ✓
  • Then: the merchant’s balance grew ✓
  • Then: the reclaimed rent (less fees) lands with the owner ✓

Result: ✓ success — 365 CU · claims 6/6 ✓

sequenceDiagram
    participant p0 as merchant
    participant p1 as Subscriptions
    p0->>p1: deletePlan
    activate p1
    p1-->>p0: ✓ 365cu
    deactivate p1

Authority

flowchart LR
    Subscriptions["Subscriptions"]:::program
    merchant(["merchant"]):::signer
    planmerchant1[("plan(merchant, 1)")]:::state
    merchant -->|signs| Subscriptions
    Subscriptions -->|writes| planmerchant1
    classDef program fill:#dae8fc,stroke:#6c8ebf;
    classDef signer fill:#d5e8d4,stroke:#82b366;
    classDef state fill:#ffe6cc,stroke:#d79b00;

Ownership

flowchart LR
    system["system"]:::program
    merchant[("merchant")]:::state
    planmerchant1[("plan(merchant, 1)")]:::state
    system -->|owns| merchant
    system -->|owns| planmerchant1
    classDef program fill:#dae8fc,stroke:#6c8ebf;
    classDef signer fill:#d5e8d4,stroke:#82b366;
    classDef state fill:#ffe6cc,stroke:#d79b00;

Accounts

AccountSignerWritableOwner
merchantsystem
plan(merchant, 1)·system
Call tree and logs
A merchant deletes a plan
merchant (365cu)
└─ Subscriptions::deletePlan ✓ 365cu
Program De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44 invoke [1]
Program De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44 consumed 365 of 200000 compute units
Program De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44 success

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};

// --- deletePlan ---
let program_id: Pubkey = pubkey!("De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44");
let owner: Pubkey = pubkey!("4jnW4cX63scWb3VGDVaacYunDWNFsGERr3YYUKPuNxJp"); // merchant: this scenario's value; supply your own
let plan_pda: Pubkey = pubkey!("2PQuEytmHE7C2a9ynpmsRfYK7UgXNtCqSR4qnNNgeZwp"); // plan(merchant, 1): this scenario's value; supply your own
let ix = Instruction {
    program_id,
    accounts: vec![
        AccountMeta::new(owner, true), // owner
        AccountMeta::new(plan_pda, false), // planPda
    ],
    // discriminator ++ borsh()
    data: vec![0x09],
};
}

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 { getDeletePlanInstructionAsync } from "your-generated-client";

const ix = await getDeletePlanInstructionAsync({
  owner: ownerSigner, // merchant (a TransactionSigner)
  planPda: address("2PQuEytmHE7C2a9ynpmsRfYK7UgXNtCqSR4qnNNgeZwp"), // plan(merchant, 1)
});
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 delete_plan_happy_path() {
    let mut story = Story::load(SO, IDL);
    let end_ts = story.now() + days(2);
    let plan_pda = stage_plan(&mut story, end_ts);
    let owner = merchant(&mut story);

    let update_outcome = story.update_plan(
        &owner,
        plan_pda,
        UpdatePlanArgs {
            status: STATUS_SUNSET,
            end_ts,
            ..Default::default()
        },
    );
    story.then_holds("the merchant sunsets the plan", update_outcome.success);
    story.warp(days(3) as u64);

    let rent = lamports(&story, &plan_pda);
    story.then_holds("the plan account holds rent before deletion", rent > 0);
    let owner_before = lamports(&story, &owner.pubkey());

    let outcome = story.delete_plan(&owner, plan_pda);
    story.then_holds("the merchant deletes the expired plan", outcome.success);

    story.then_holds("the plan account is drained", is_drained(&story, &plan_pda));
    let owner_after = lamports(&story, &owner.pubkey());
    story.then_holds("the merchant's balance grew", owner_after > owner_before);
    story.then(
        "the reclaimed rent (less fees) lands with the owner",
        move |_| owner_after >= owner_before + rent - 10_000,
    );
    emit_scenario(&story, &outcome, "A merchant deletes a plan");
}
}

Subscribing

The subscriber journey: subscribe to a plan, which creates a delegation capped by that plan’s amount and period; cancel when you want out (the permission dies, the history stays); resume a cancelled subscription to pick the agreement back up without re-negotiating terms.

A subscriber subscribes to a plan

Source: subscriptions/tests/test_subscribe.rs L181

  • When: the user initializes a subscription authority
  • When: the merchant creates plan 1
  • When: the subscriber subscribes
  • Then: subscribe executes source-free ✓
  • Then: subscriptionDelegation.header.discriminator = 4 ✓
  • Then: subscriptionDelegation.header.delegator = 2rj1EEgg32bupe12mMYnPFaKQCPPyYBhKYmDFZ8cXxpT ✓
  • Then: subscriptionDelegation.header.delegatee = 2PQuEytmHE7C2a9ynpmsRfYK7UgXNtCqSR4qnNNgeZwp ✓
  • Then: subscriptionDelegation.header.payer = 2rj1EEgg32bupe12mMYnPFaKQCPPyYBhKYmDFZ8cXxpT ✓
  • Then: subscriptionDelegation.amountPulledInPeriod = 0 ✓
  • Then: subscriptionDelegation.expiresAtTs = 0 ✓

Result: ✓ success — 9563 CU · claims 7/7 ✓

sequenceDiagram
    participant p0 as alice
    participant p1 as Subscriptions
    participant p2 as system
    p0->>p1: subscribe
    activate p1
    p1->>p2: createAccount
    activate p2
    p2-->>p1: ✓
    deactivate p2
    p1->>p1: emit:subscriptionCreatedEvent
    activate p1
    p1-->>p1: ✓ 137cu
    deactivate p1
    p1-->>p0: ✓ 9563cu
    deactivate p1

Authority

flowchart LR
    Subscriptions["Subscriptions"]:::program
    alice(["alice"]):::signer
    Subscription(["Subscription"]):::signer
    system["system"]:::program
    eventAuthority(["eventAuthority"]):::signer
    alice -->|signs| Subscriptions
    Subscriptions -->|writes| Subscription
    alice -->|signs| system
    Subscription -->|signs| system
    eventAuthority -->|signs| Subscriptions
    classDef program fill:#dae8fc,stroke:#6c8ebf;
    classDef signer fill:#d5e8d4,stroke:#82b366;
    classDef state fill:#ffe6cc,stroke:#d79b00;

Ownership

flowchart LR
    system["system"]:::program
    alice[("alice")]:::state
    Subscriptions["Subscriptions"]:::program
    Subscription[("Subscription")]:::state
    system -->|owns| alice
    Subscriptions -->|owns| Subscription
    classDef program fill:#dae8fc,stroke:#6c8ebf;
    classDef signer fill:#d5e8d4,stroke:#82b366;
    classDef state fill:#ffe6cc,stroke:#d79b00;

Accounts

AccountSignerWritableOwner
alicesystem
merchant··system
Plan··Subscriptions
Subscription·Subscriptions
SA(alice, USDC mint)··Subscriptions
system··Nati…
eventAuthority··system
Subscriptions··BPFL…
Call tree and logs
A subscriber subscribes to a plan
alice (9563cu)
└─ Subscriptions::subscribe ✓ 9563cu
   ├─ system::createAccount ✓
   └─ Subscriptions::emit:subscriptionCreatedEvent ✓ 137cu
Program De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44 invoke [1]
Program 11111111111111111111111111111111 invoke [2]
Program 11111111111111111111111111111111 success
Program De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44 invoke [2]
Program De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44 consumed 137 of 190600 compute units
Program De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44 success
Program De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44 consumed 9563 of 200000 compute units
Program De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44 success

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};

// --- subscribe ---
let program_id: Pubkey = pubkey!("De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44");
let subscriber: Pubkey = pubkey!("2rj1EEgg32bupe12mMYnPFaKQCPPyYBhKYmDFZ8cXxpT"); // alice: this scenario's value; supply your own
let merchant: Pubkey = pubkey!("4jnW4cX63scWb3VGDVaacYunDWNFsGERr3YYUKPuNxJp"); // merchant: this scenario's value; supply your own
let plan_pda: Pubkey = pubkey!("2PQuEytmHE7C2a9ynpmsRfYK7UgXNtCqSR4qnNNgeZwp"); // Plan: this scenario's value; supply your own
let subscription_authority_pda: Pubkey = pubkey!("4RDKDQRnKtZC5ti4wunSAayArwxdZida5St595CiKJ7i"); // SA(alice, USDC mint): this scenario's value; supply your own
let system_program: Pubkey = pubkey!("11111111111111111111111111111111"); // the system program
let subscription_pda = Pubkey::find_program_address(&[b"subscription", plan_pda.as_ref(), subscriber.as_ref()], &program_id).0;
let event_authority = Pubkey::find_program_address(&[b"event_authority"], &program_id).0;
let ix = Instruction {
    program_id,
    accounts: vec![
        AccountMeta::new(subscriber, true), // subscriber
        AccountMeta::new_readonly(merchant, false), // merchant
        AccountMeta::new_readonly(plan_pda, false), // planPda
        AccountMeta::new(subscription_pda, false), // subscriptionPda
        AccountMeta::new_readonly(subscription_authority_pda, false), // subscriptionAuthorityPda
        AccountMeta::new_readonly(system_program, false), // systemProgram
        AccountMeta::new_readonly(event_authority, false), // eventAuthority
        AccountMeta::new_readonly(program_id, false), // selfProgram
    ],
    // discriminator ++ borsh(subscribeData: {planId: 1, planBump: 255, expectedMint: Hhbh…, expectedAmount: 50000000, expectedPeriodHours: 1, expectedCreatedAt: 0, expectedSubscriptionAuthorityInitId: 0})
    data: vec![0x0b, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 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, 0x80, 0xf0, 0xfa, 0x02, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00],
};
}

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 { getSubscribeInstructionAsync } from "your-generated-client";

const ix = await getSubscribeInstructionAsync({
  subscriber: subscriberSigner, // alice (a TransactionSigner)
  merchant: address("4jnW4cX63scWb3VGDVaacYunDWNFsGERr3YYUKPuNxJp"), // merchant
  planPda: address("2PQuEytmHE7C2a9ynpmsRfYK7UgXNtCqSR4qnNNgeZwp"), // Plan
  subscriptionAuthorityPda: address("4RDKDQRnKtZC5ti4wunSAayArwxdZida5St595CiKJ7i"), // SA(alice, USDC mint)
  // systemProgram defaults to the system program
  subscribeData: {
    planId: 1n,
    planBump: 255,
    expectedMint: address("HhbhtPWNt5Rd2gvrjhGCekommdNQwVLY6ZMp3aBv4xtG") /* USDC mint */,
    expectedAmount: 50000000n,
    expectedPeriodHours: 1n,
    expectedCreatedAt: 0n,
    expectedSubscriptionAuthorityInitId: 0n
  },
});
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 subscribe_happy_path() {
    let mut story = Story::load(SO, IDL);
    let end_ts = story.now() + days(30) as i64;
    let (alice, merchant, mint, plan_pda, _bump) = setup_plan(&mut story, 1, end_ts);

    let subscription_pda = story.subscription_pda(&plan_pda, &alice.pubkey());
    story.alias(subscription_pda, "Subscription");
    let outcome = story.subscribe(&alice, &merchant.pubkey(), mint, 1);
    story.then_holds("subscribe executes source-free", outcome.success);

    // The subscription decodes against its named type; its header round-trips.
    let discriminator = story.track_field::<u8>(
        "subscriptionDelegation",
        subscription_pda,
        "header.discriminator",
    );
    let delegator = story.track_field::<Pubkey>(
        "subscriptionDelegation",
        subscription_pda,
        "header.delegator",
    );
    let delegatee = story.track_field::<Pubkey>(
        "subscriptionDelegation",
        subscription_pda,
        "header.delegatee",
    );
    let payer =
        story.track_field::<Pubkey>("subscriptionDelegation", subscription_pda, "header.payer");
    let amount_pulled = story.track_field::<u64>(
        "subscriptionDelegation",
        subscription_pda,
        "amountPulledInPeriod",
    );
    let expires_at =
        story.track_field::<i64>("subscriptionDelegation", subscription_pda, "expiresAtTs");

    story.then_eq(&discriminator, DISC_SUBSCRIPTION_DELEGATION);
    story.then_eq(&delegator, alice.pubkey());
    story.then_eq(&delegatee, plan_pda);
    story.then_eq(&payer, alice.pubkey());
    story.then_eq(&amount_pulled, 0u64);
    story.then_eq(&expires_at, 0i64);
    emit_scenario(&story, &outcome, "A subscriber subscribes to a plan");
}
}

A subscriber cancels their subscription

Source: subscriptions/tests/test_cancel_subscription.rs L149

  • When: the user initializes a subscription authority
  • When: the merchant creates plan 1
  • When: the subscriber subscribes
  • When: the subscriber cancels
  • Then: the cancelled subscription gets an end-of-period expiry ✓

Result: ✓ success — 1957 CU · claims 1/1 ✓

sequenceDiagram
    participant p0 as alice
    participant p1 as Subscriptions
    p0->>p1: cancelSubscription
    activate p1
    p1->>p1: emit:subscriptionCancelledEvent
    activate p1
    p1-->>p1: ✓ 137cu
    deactivate p1
    p1-->>p0: ✓ 1957cu
    deactivate p1

Authority

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

Ownership

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

Accounts

AccountSignerWritableOwner
alicesystem
plan(merchant, 1)··Subscriptions
subscription(plan(merchant, 1), alice)·Subscriptions
eventAuthority··system
Subscriptions··BPFL…
Call tree and logs
A subscriber cancels their subscription
alice (1957cu)
└─ Subscriptions::cancelSubscription ✓ 1957cu
   └─ Subscriptions::emit:subscriptionCancelledEvent ✓ 137cu
Program De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44 invoke [1]
Program De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44 invoke [2]
Program De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44 consumed 137 of 198203 compute units
Program De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44 success
Program De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44 consumed 1957 of 200000 compute units
Program De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44 success

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};

// --- cancelSubscription ---
let program_id: Pubkey = pubkey!("De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44");
let subscriber: Pubkey = pubkey!("2rj1EEgg32bupe12mMYnPFaKQCPPyYBhKYmDFZ8cXxpT"); // 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_pda: Pubkey = pubkey!("6pQDdNgFksFRPMJQFvuiMheurG16kVi12So7UutPzWjH"); // subscription(plan(merchant, 1), alice): this scenario's value; supply your own
let event_authority = Pubkey::find_program_address(&[b"event_authority"], &program_id).0;
let ix = Instruction {
    program_id,
    accounts: vec![
        AccountMeta::new_readonly(subscriber, true), // subscriber
        AccountMeta::new_readonly(plan_pda, false), // planPda
        AccountMeta::new(subscription_pda, false), // subscriptionPda
        AccountMeta::new_readonly(event_authority, false), // eventAuthority
        AccountMeta::new_readonly(program_id, false), // selfProgram
    ],
    // discriminator ++ borsh()
    data: vec![0x0c],
};
}

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 { getCancelSubscriptionInstructionAsync } from "your-generated-client";

const ix = await getCancelSubscriptionInstructionAsync({
  subscriber: subscriberSigner, // alice (a TransactionSigner)
  planPda: address("2PQuEytmHE7C2a9ynpmsRfYK7UgXNtCqSR4qnNNgeZwp"), // plan(merchant, 1)
  subscriptionPda: address("6pQDdNgFksFRPMJQFvuiMheurG16kVi12So7UutPzWjH"), // subscription(plan(merchant, 1), alice)
});
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 cancel_subscription_happy_path() {
    let mut story = Story::load(SO, IDL);
    let s = story.stage_subscription();

    let outcome = story
        .cancel_subscription(&s.alice, s.plan_pda, s.subscription_pda)
        .expect_success();

    let expires = expires_at(&story, &s.subscription_pda);
    story.then_holds(
        "the cancelled subscription gets an end-of-period expiry",
        expires != 0,
    );
    emit_scenario(&story, &outcome, "A subscriber cancels their subscription");
}
}

A subscriber resumes a cancelled subscription

Source: subscriptions/tests/test_resume_subscription.rs L128

  • When: the user initializes a subscription authority
  • When: the merchant creates plan 1
  • When: the subscriber subscribes
  • When: the subscriber cancels
  • Then: the cancelled subscription has an expiry ✓
  • When: the subscriber resumes
  • Then: the expiry is cleared ✓
  • Then: period start preserved ✓
  • Then: amount pulled preserved ✓

Result: ✓ success — 1926 CU · claims 4/4 ✓

sequenceDiagram
    participant p0 as alice
    participant p1 as Subscriptions
    p0->>p1: resumeSubscription
    activate p1
    p1->>p1: emit:subscriptionResumedEvent
    activate p1
    p1-->>p1: ✓ 137cu
    deactivate p1
    p1-->>p0: ✓ 1926cu
    deactivate p1

Authority

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

Ownership

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

Accounts

AccountSignerWritableOwner
alicesystem
plan(merchant, 1)··Subscriptions
subscription(plan(merchant, 1), alice)·Subscriptions
SA(alice, Mint)··Subscriptions
eventAuthority··system
Subscriptions··BPFL…
Call tree and logs
A subscriber resumes a cancelled subscription
alice (1926cu)
└─ Subscriptions::resumeSubscription ✓ 1926cu
   └─ Subscriptions::emit:subscriptionResumedEvent ✓ 137cu
Program De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44 invoke [1]
Program De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44 invoke [2]
Program De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44 consumed 137 of 198234 compute units
Program De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44 success
Program De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44 consumed 1926 of 200000 compute units
Program De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44 success

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};

// --- resumeSubscription ---
let program_id: Pubkey = pubkey!("De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44");
let subscriber: Pubkey = pubkey!("2rj1EEgg32bupe12mMYnPFaKQCPPyYBhKYmDFZ8cXxpT"); // 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_pda: Pubkey = pubkey!("6pQDdNgFksFRPMJQFvuiMheurG16kVi12So7UutPzWjH"); // subscription(plan(merchant, 1), alice): this scenario's value; supply your own
let subscription_authority: Pubkey = pubkey!("BXjfuP23wCLiPPwdhYow6hVtETMW4gzosqTbJarvxN8U"); // SA(alice, Mint): this scenario's value; supply your own
let event_authority = Pubkey::find_program_address(&[b"event_authority"], &program_id).0;
let ix = Instruction {
    program_id,
    accounts: vec![
        AccountMeta::new_readonly(subscriber, true), // subscriber
        AccountMeta::new_readonly(plan_pda, false), // planPda
        AccountMeta::new(subscription_pda, false), // subscriptionPda
        AccountMeta::new_readonly(subscription_authority, false), // subscriptionAuthority
        AccountMeta::new_readonly(event_authority, false), // eventAuthority
        AccountMeta::new_readonly(program_id, false), // selfProgram
    ],
    // discriminator ++ borsh()
    data: vec![0x0d],
};
}

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 { getResumeSubscriptionInstructionAsync } from "your-generated-client";

const ix = await getResumeSubscriptionInstructionAsync({
  subscriber: subscriberSigner, // alice (a TransactionSigner)
  planPda: address("2PQuEytmHE7C2a9ynpmsRfYK7UgXNtCqSR4qnNNgeZwp"), // plan(merchant, 1)
  subscriptionPda: address("6pQDdNgFksFRPMJQFvuiMheurG16kVi12So7UutPzWjH"), // subscription(plan(merchant, 1), alice)
  subscriptionAuthority: address("BXjfuP23wCLiPPwdhYow6hVtETMW4gzosqTbJarvxN8U"), // SA(alice, 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 resume_subscription_happy_path() {
    let mut story = Story::load(SO, IDL);
    let s = story.stage_subscription();

    story
        .cancel_subscription(&s.alice, s.plan_pda, s.subscription_pda)
        .expect_success();
    let period_start = field_i64(&story, &s.subscription_pda, "currentPeriodStartTs");
    let amount_pulled = field_u64(&story, &s.subscription_pda, "amountPulledInPeriod");
    let expires_at = field_i64(&story, &s.subscription_pda, "expiresAtTs");
    story.then("the cancelled subscription has an expiry", move |_| {
        expires_at != 0
    });

    let outcome = story
        .resume_subscription(&s.alice, s.plan_pda, s.subscription_pda, s.mint)
        .expect_success();

    let expires_after = field_i64(&story, &s.subscription_pda, "expiresAtTs");
    story.then("the expiry is cleared", move |_| expires_after == 0);
    let period_start_after = field_i64(&story, &s.subscription_pda, "currentPeriodStartTs");
    story.then("period start preserved", move |_| {
        period_start_after == period_start
    });
    let amount_pulled_after = field_u64(&story, &s.subscription_pda, "amountPulledInPeriod");
    story.then("amount pulled preserved", move |_| {
        amount_pulled_after == amount_pulled
    });
    emit_scenario(
        &story,
        &outcome,
        "A subscriber resumes a cancelled subscription",
    );
}
}

Collecting payments

The other side of a subscription: each period, the puller transfers the plan amount from the subscriber, authorized by the delegation rather than by a fresh signature. The refusal cases here are the section’s real payload: a ghost plan (terms swapped after subscription) can neither inflate the pull amount nor extend the pull window past what the subscriber originally agreed to. The cap the subscriber granted is the cap that holds.

A ghost plan cannot extend the pull window past the original agreement

Source: subscriptions/tests/audit_high_ghost_plan.rs L202

  • 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 post-original-end 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 extend the pull window past the original agreement
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: 10000000, delegator: 2rj1…, mint: Hhbh…})
    data: vec![0x0a, 0x80, 0x96, 0x98, 0x00, 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: 10000000n,
    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_4_ghost_plan_extended_end_ts_is_refused() {
    let mut story = Story::load(SO, IDL);
    // Same amount, but the ghost plan extends end_ts 60 days out. Staging warps
    // 3h, past the ORIGINAL 2h end_ts, so a pull would be past the agreement.
    let g = stage_and_recreate(&mut story, 50_000_000, 50_000_000, 60);

    let merchant_before = story.token_balance(&g.merchant.ata(g.mint));
    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: 10_000_000,
            to: None,
        },
    );

    story.then_holds("the post-original-end pull is refused", !pull.success);
    emit_scenario(
        &story,
        &pull,
        "A ghost plan cannot extend the pull window past the original agreement",
    );
    story.then(
        "refusal carries custom error code for PlanTermsMismatch",
        move |_s| refused_with(&pull, PLAN_TERMS_MISMATCH),
    );
    story.then(
        "the merchant siphons nothing past the original end",
        move |s| s.token_balance(&g.merchant.ata(g.mint)) == merchant_before,
    );
}
}

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,
    );
}
}

A puller transfers against a subscription

Source: subscriptions/tests/test_transfer_subscription.rs L199

  • When: the user initializes a subscription authority
  • Then: init authority succeeds ✓
  • When: the merchant creates plan 1
  • Then: create plan succeeds ✓
  • When: the subscriber subscribes
  • Then: subscribe succeeds ✓
  • Then: the merchant ATA starts empty ✓
  • When: a puller transfers against the subscription
  • Then: the merchant received 10 tokens ✓
  • Then: the pulled-in-period is 10 tokens ✓

Result: ✓ success — 5973 CU · claims 6/6 ✓

sequenceDiagram
    participant p0 as merchant
    participant p1 as Subscriptions
    participant p2 as token
    p0->>p1: transferSubscription
    activate p1
    p1->>p2: transferChecked
    activate p2
    p2-->>p1: ✓ 113cu
    deactivate p2
    p1->>p1: emit:subscriptionTransferEvent
    activate p1
    p1-->>p1: ✓ 137cu
    deactivate p1
    p1-->>p0: ✓ 5973cu
    deactivate p1

Authority

flowchart LR
    Subscriptions["Subscriptions"]:::program
    Subscription[("Subscription")]:::state
    aliceATAUSDCmint[("alice/ATA(USDC mint)")]:::state
    merchantATAUSDCmint[("merchant/ATA(USDC mint)")]:::state
    merchant(["merchant"]):::signer
    token["token"]:::program
    SAaliceUSDCmint(["SA(alice, USDC mint)"]):::signer
    eventAuthority(["eventAuthority"]):::signer
    Subscriptions -->|writes| Subscription
    Subscriptions -->|writes| aliceATAUSDCmint
    Subscriptions -->|writes| merchantATAUSDCmint
    merchant -->|signs| Subscriptions
    token -->|writes| aliceATAUSDCmint
    token -->|writes| merchantATAUSDCmint
    SAaliceUSDCmint -->|signs| token
    eventAuthority -->|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
    Subscription[("Subscription")]:::state
    token["token"]:::program
    aliceATAUSDCmint[("alice/ATA(USDC mint)")]:::state
    merchantATAUSDCmint[("merchant/ATA(USDC mint)")]:::state
    system["system"]:::program
    merchant[("merchant")]:::state
    Subscriptions -->|owns| Subscription
    token -->|owns| aliceATAUSDCmint
    token -->|owns| merchantATAUSDCmint
    system -->|owns| merchant
    classDef program fill:#dae8fc,stroke:#6c8ebf;
    classDef signer fill:#d5e8d4,stroke:#82b366;
    classDef state fill:#ffe6cc,stroke:#d79b00;

Accounts

AccountSignerWritableOwner
Subscription·Subscriptions
Plan··Subscriptions
SA(alice, USDC mint)··Subscriptions
alice/ATA(USDC mint)·token
merchant/ATA(USDC mint)·token
merchantsystem
USDC mint··token
token··BPFL…
eventAuthority··system
Subscriptions··BPFL…
Call tree and logs
A puller transfers against a subscription
merchant (5973cu)
└─ Subscriptions::transferSubscription ✓ 5973cu
   ├─ token::transferChecked ✓ 113cu
   └─ Subscriptions::emit:subscriptionTransferEvent ✓ 137cu
Program De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44 invoke [1]
Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [2]
Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 113 of 195646 compute units
Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success
Program De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44 invoke [2]
Program De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44 consumed 137 of 194189 compute units
Program De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44 success
Program De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44 consumed 5973 of 200000 compute units
Program De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44 success

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: this scenario's value; supply your own
let plan_pda: Pubkey = pubkey!("2PQuEytmHE7C2a9ynpmsRfYK7UgXNtCqSR4qnNNgeZwp"); // Plan: this scenario's value; supply your own
let subscription_authority: Pubkey = pubkey!("4RDKDQRnKtZC5ti4wunSAayArwxdZida5St595CiKJ7i"); // SA(alice, USDC 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"); // USDC 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: 10000000, delegator: 2rj1…, mint: Hhbh…})
    data: vec![0x0a, 0x80, 0x96, 0x98, 0x00, 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
  planPda: address("2PQuEytmHE7C2a9ynpmsRfYK7UgXNtCqSR4qnNNgeZwp"), // Plan
  subscriptionAuthority: address("4RDKDQRnKtZC5ti4wunSAayArwxdZida5St595CiKJ7i"), // SA(alice, USDC mint)
  delegatorAta: address("3fVRopUs1BsF6RqteSmcyctBCx2YQ6Zkv7prsUz95VHY"),
  receiverAta: address("3FuYuMdLiEnmSsoGZEiXUDJSpiLgeKTmNTWUQkLAZyH1"),
  caller: callerSigner, // merchant (a TransactionSigner)
  tokenMint: address("HhbhtPWNt5Rd2gvrjhGCekommdNQwVLY6ZMp3aBv4xtG"), // USDC mint
  // tokenProgram defaults to the token program
  transferData: {
    amount: 10000000n,
    delegator: address("2rj1EEgg32bupe12mMYnPFaKQCPPyYBhKYmDFZ8cXxpT") /* alice */,
    mint: address("HhbhtPWNt5Rd2gvrjhGCekommdNQwVLY6ZMp3aBv4xtG") /* USDC 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 transfer_subscription_success() {
    let mut story = Story::load(SO, IDL);
    let end_ts = story.now() + days(30) as i64;
    let s = setup_plan_and_subscription(&mut story, 50_000_000, 1, end_ts, vec![], vec![]);

    story.then_holds(
        "the merchant ATA starts empty",
        story.token_balance(&s.merchant_ata) == 0,
    );

    let outcome = pull(&mut story, &s, &s.merchant, 10_000_000, None).expect_success();

    story.then_holds(
        "the merchant received 10 tokens",
        story.token_balance(&s.merchant_ata) == 10_000_000,
    );
    story.then_holds(
        "the pulled-in-period is 10 tokens",
        amount_pulled(&story, &s.subscription_pda) == 10_000_000,
    );
    emit_scenario(
        &story,
        &outcome,
        "A puller transfers against a subscription",
    );
}
}

Direct delegations

Delegations without a plan, for when two parties set terms directly. A fixed delegation authorizes pulls within one window and total; a recurring delegation authorizes a per-period cap that renews. Pulls against each are their own instructions, timing bounds are enforced (a recurring pull before the period begins is refused; drift tolerance cannot stretch a fixed window past expiry), and both the authority and, for abandoned delegations, anyone can clean up.

A delegator creates a fixed delegation

Source: subscriptions/tests/test_create_fixed_delegation.rs L206

  • When: the user initializes a subscription authority
  • When: a fixed delegation is created
  • Then: the fixed delegation is created ✓
  • Then: fixedDelegation.header.delegator = 2rj1EEgg32bupe12mMYnPFaKQCPPyYBhKYmDFZ8cXxpT ✓
  • Then: fixedDelegation.header.delegatee = 111FJo4zLAGU9nzTWa6EnbV4VAmtG4FR8kcokrtZYr ✓
  • Then: fixedDelegation.header.discriminator = 2 ✓
  • Then: fixedDelegation.amount = 100000000 ✓
  • Then: fixedDelegation.expiryTs = 86400 ✓

Result: ✓ success — 6538 CU · claims 6/6 ✓

sequenceDiagram
    participant p0 as alice
    participant p1 as Subscriptions
    participant p2 as system
    p0->>p1: createFixedDelegation
    activate p1
    p1->>p2: createAccount
    activate p2
    p2-->>p1: ✓
    deactivate p2
    p1-->>p0: ✓ 6538cu
    deactivate p1

Authority

flowchart LR
    Subscriptions["Subscriptions"]:::program
    alice(["alice"]):::signer
    7JW9(["7JW9…"]):::signer
    system["system"]:::program
    alice -->|signs| Subscriptions
    Subscriptions -->|writes| 7JW9
    alice -->|signs| system
    7JW9 -->|signs| system
    classDef program fill:#dae8fc,stroke:#6c8ebf;
    classDef signer fill:#d5e8d4,stroke:#82b366;
    classDef state fill:#ffe6cc,stroke:#d79b00;

Ownership

flowchart LR
    system["system"]:::program
    alice[("alice")]:::state
    Subscriptions["Subscriptions"]:::program
    7JW9[("7JW9…")]:::state
    system -->|owns| alice
    Subscriptions -->|owns| 7JW9
    classDef program fill:#dae8fc,stroke:#6c8ebf;
    classDef signer fill:#d5e8d4,stroke:#82b366;
    classDef state fill:#ffe6cc,stroke:#d79b00;

Accounts

AccountSignerWritableOwner
alicesystem
SA(alice, Mint)··Subscriptions
7JW9…·Subscriptions
111F…··system
system··Nati…
Call tree and logs
A delegator creates a fixed delegation
alice (6538cu)
└─ Subscriptions::createFixedDelegation ✓ 6538cu
   └─ system::createAccount ✓
Program De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44 invoke [1]
Program 11111111111111111111111111111111 invoke [2]
Program 11111111111111111111111111111111 success
Program De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44 consumed 6538 of 200000 compute units
Program De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44 success

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};

// --- createFixedDelegation ---
let program_id: Pubkey = pubkey!("De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44");
let delegator: Pubkey = pubkey!("2rj1EEgg32bupe12mMYnPFaKQCPPyYBhKYmDFZ8cXxpT"); // alice: this scenario's value; supply your own
let subscription_authority: Pubkey = pubkey!("4RDKDQRnKtZC5ti4wunSAayArwxdZida5St595CiKJ7i"); // SA(alice, Mint): this scenario's value; supply your own
let delegation_account: Pubkey = pubkey!("7JW9nfbPZbx3UQUbDA69x9D9Tytmmh23p7wYJ6Eq9RWM"); // supply your own
let delegatee: Pubkey = pubkey!("111FJo4zLAGU9nzTWa6EnbV4VAmtG4FR8kcokrtZYr"); // supply your own
let system_program: Pubkey = pubkey!("11111111111111111111111111111111"); // the system program
let ix = Instruction {
    program_id,
    accounts: vec![
        AccountMeta::new(delegator, true), // delegator
        AccountMeta::new_readonly(subscription_authority, false), // subscriptionAuthority
        AccountMeta::new(delegation_account, false), // delegationAccount
        AccountMeta::new_readonly(delegatee, false), // delegatee
        AccountMeta::new_readonly(system_program, false), // systemProgram
    ],
    // discriminator ++ borsh(fixedDelegation: {nonce: 0, amount: 100000000, expiryTs: 86400, expectedSubscriptionAuthorityInitId: 0})
    data: vec![0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe1, 0xf5, 0x05, 0x00, 0x00, 0x00, 0x00, 0x80, 0x51, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00],
};
}

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 { getCreateFixedDelegationInstructionAsync } from "your-generated-client";

const ix = await getCreateFixedDelegationInstructionAsync({
  delegator: delegatorSigner, // alice (a TransactionSigner)
  subscriptionAuthority: address("4RDKDQRnKtZC5ti4wunSAayArwxdZida5St595CiKJ7i"), // SA(alice, Mint)
  delegationAccount: address("7JW9nfbPZbx3UQUbDA69x9D9Tytmmh23p7wYJ6Eq9RWM"),
  delegatee: address("111FJo4zLAGU9nzTWa6EnbV4VAmtG4FR8kcokrtZYr"),
  // systemProgram defaults to the system program
  fixedDelegation: {
    nonce: 0n,
    amount: 100000000n,
    expiryTs: 86400n,
    expectedSubscriptionAuthorityInitId: 0n
  },
});
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 create_fixed_delegation() {
    let mut story = Story::load(SO, IDL);
    let payer = story.cast("alice");
    let amount: u64 = 100_000_000;
    let expiry_ts = story.now() + days(1) as i64;
    let nonce: u64 = 0;

    let mint = story.mint(&payer, MINT_DECIMALS);
    story.fund(&payer, &[(mint, 1_000_000)]);
    init(&mut story, &payer, mint);

    let delegatee = Pubkey::new_unique();
    let delegation_pda = story.delegation_pda(&payer.pubkey(), &mint, &delegatee, nonce);

    let out = story.create_fixed_delegation(CreateFixedArgs {
        delegator: &payer,
        mint,
        delegatee,
        nonce,
        amount,
        expiry_ts,
        sponsor: None,
        expected_init_id: None,
        delegation_override: None,
    });
    story.then_holds("the fixed delegation is created", out.success);

    let delegator =
        story.track_field::<Pubkey>("fixedDelegation", delegation_pda, "header.delegator");
    let delegatee_field =
        story.track_field::<Pubkey>("fixedDelegation", delegation_pda, "header.delegatee");
    let discriminator =
        story.track_field::<u8>("fixedDelegation", delegation_pda, "header.discriminator");
    let amount_field = story.track_field::<u64>("fixedDelegation", delegation_pda, "amount");
    let expiry = story.track_field::<i64>("fixedDelegation", delegation_pda, "expiryTs");

    story.then_eq(&delegator, payer.pubkey());
    story.then_eq(&delegatee_field, delegatee);
    story.then_eq(&discriminator, TAG_FIXED_DELEGATION);
    story.then_eq(&amount_field, amount);
    story.then_eq(&expiry, expiry_ts);
    emit_scenario(&story, &out, "A delegator creates a fixed delegation");
}
}

A delegatee pulls against a fixed delegation

Source: subscriptions/tests/test_transfer_fixed_delegation.rs L160

  • When: the user initializes a subscription authority
  • Then: initSubscriptionAuthority succeeds ✓
  • When: a fixed delegation is created
  • Then: createFixedDelegation succeeds ✓
  • Then: Bob’s ATA starts empty ✓
  • When: the delegatee pulls against the delegation
  • Then: transfer_fixed succeeds ✓
  • Then: Bob received 30 tokens ✓
  • Then: fixedDelegation.amount = 20000000 ✓
  • Then: fixedDelegation.expiryTs = 86400 ✓

Result: ✓ success — 5554 CU · claims 7/7 ✓

sequenceDiagram
    participant p0 as bob
    participant p1 as Subscriptions
    participant p2 as token
    p0->>p1: transferFixed
    activate p1
    p1->>p2: transferChecked
    activate p2
    p2-->>p1: ✓ 113cu
    deactivate p2
    p1->>p1: emit:fixedTransferEvent
    activate p1
    p1-->>p1: ✓ 137cu
    deactivate p1
    p1-->>p0: ✓ 5554cu
    deactivate p1

Authority

flowchart LR
    Subscriptions["Subscriptions"]:::program
    B1u4[("B1u4…")]:::state
    aliceATAMint[("alice/ATA(Mint)")]:::state
    bobATAMint[("bob/ATA(Mint)")]:::state
    bob(["bob"]):::signer
    token["token"]:::program
    SAaliceMint(["SA(alice, Mint)"]):::signer
    eventAuthority(["eventAuthority"]):::signer
    Subscriptions -->|writes| B1u4
    Subscriptions -->|writes| aliceATAMint
    Subscriptions -->|writes| bobATAMint
    bob -->|signs| Subscriptions
    token -->|writes| aliceATAMint
    token -->|writes| bobATAMint
    SAaliceMint -->|signs| token
    eventAuthority -->|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
    B1u4[("B1u4…")]:::state
    token["token"]:::program
    aliceATAMint[("alice/ATA(Mint)")]:::state
    bobATAMint[("bob/ATA(Mint)")]:::state
    system["system"]:::program
    bob[("bob")]:::state
    Subscriptions -->|owns| B1u4
    token -->|owns| aliceATAMint
    token -->|owns| bobATAMint
    system -->|owns| bob
    classDef program fill:#dae8fc,stroke:#6c8ebf;
    classDef signer fill:#d5e8d4,stroke:#82b366;
    classDef state fill:#ffe6cc,stroke:#d79b00;

Accounts

AccountSignerWritableOwner
B1u4…·Subscriptions
SA(alice, Mint)··Subscriptions
alice/ATA(Mint)·token
bob/ATA(Mint)·token
Mint··token
token··BPFL…
bobsystem
eventAuthority··system
Subscriptions··BPFL…
Call tree and logs
A delegatee pulls against a fixed delegation
bob (5554cu)
└─ Subscriptions::transferFixed ✓ 5554cu
   ├─ token::transferChecked ✓ 113cu
   └─ Subscriptions::emit:fixedTransferEvent ✓ 137cu
Program De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44 invoke [1]
Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [2]
Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 113 of 195993 compute units
Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success
Program De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44 invoke [2]
Program De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44 consumed 137 of 194605 compute units
Program De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44 success
Program De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44 consumed 5554 of 200000 compute units
Program De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44 success

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};

// --- transferFixed ---
let program_id: Pubkey = pubkey!("De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44");
let delegation_pda: Pubkey = pubkey!("B1u4dvZ4DrFrUm3tm4K7zqN1Nb9fu2NuAFExDfyDocqg"); // 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!("5UJQzG1wu6euuhQRnsHbqjKC8DHmfS4jVNzghS3PL4Hx"); // 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 delegatee: Pubkey = pubkey!("6DAhWE3pSJQrSFRNjkGQznpUi7wWnqWf3FnRyZQqxbvu"); // bob: this scenario's value; supply your own
let event_authority = Pubkey::find_program_address(&[b"event_authority"], &program_id).0;
let ix = Instruction {
    program_id,
    accounts: vec![
        AccountMeta::new(delegation_pda, false), // delegationPda
        AccountMeta::new_readonly(subscription_authority, false), // subscriptionAuthority
        AccountMeta::new(delegator_ata, false), // delegatorAta
        AccountMeta::new(receiver_ata, false), // receiverAta
        AccountMeta::new_readonly(token_mint, false), // tokenMint
        AccountMeta::new_readonly(token_program, false), // tokenProgram
        AccountMeta::new_readonly(delegatee, true), // delegatee
        AccountMeta::new_readonly(event_authority, false), // eventAuthority
        AccountMeta::new_readonly(program_id, false), // selfProgram
    ],
    // discriminator ++ borsh(transferData: {amount: 30000000, delegator: 2rj1…, mint: Hhbh…})
    data: vec![0x04, 0x80, 0xc3, 0xc9, 0x01, 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 { getTransferFixedInstructionAsync } from "your-generated-client";

const ix = await getTransferFixedInstructionAsync({
  delegationPda: address("B1u4dvZ4DrFrUm3tm4K7zqN1Nb9fu2NuAFExDfyDocqg"),
  subscriptionAuthority: address("4RDKDQRnKtZC5ti4wunSAayArwxdZida5St595CiKJ7i"), // SA(alice, Mint)
  delegatorAta: address("3fVRopUs1BsF6RqteSmcyctBCx2YQ6Zkv7prsUz95VHY"),
  receiverAta: address("5UJQzG1wu6euuhQRnsHbqjKC8DHmfS4jVNzghS3PL4Hx"),
  tokenMint: address("HhbhtPWNt5Rd2gvrjhGCekommdNQwVLY6ZMp3aBv4xtG"), // Mint
  // tokenProgram defaults to the token program
  delegatee: delegateeSigner, // bob (a TransactionSigner)
  transferData: {
    amount: 30000000n,
    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 test_fixed_transfer_success() {
    let mut story = Story::load(SO, IDL);
    let amount: u64 = 50_000_000;
    let expiry_ts = story.now() + days(1) as i64;
    let (alice, bob, delegation_pda, mint, _alice_ata, bob_ata) =
        setup_fixed_delegation(&mut story, amount, expiry_ts, 0);

    story.then_holds("Bob's ATA starts empty", balance(&story, &bob_ata) == 0);

    let out = story.transfer_fixed(xfer(&bob, delegation_pda, alice.pubkey(), mint, 30_000_000));
    story.then_holds("transfer_fixed succeeds", out.success);

    story.then_holds(
        "Bob received 30 tokens",
        balance(&story, &bob_ata) == 30_000_000,
    );

    let remaining = story.track_field::<u64>("fixedDelegation", delegation_pda, "amount");
    story.then_eq(&remaining, 20_000_000u64);

    let expiry = story.track_field::<i64>("fixedDelegation", delegation_pda, "expiryTs");
    story.then_eq(&expiry, expiry_ts);
    emit_scenario(&story, &out, "A delegatee pulls against a fixed delegation");
}
}

The drift tolerance does not extend a fixed transfer past expiry

Source: subscriptions/tests/audit_regressions.rs L216

  • When: the user initializes a subscription authority
  • When: a fixed delegation is created
  • When: the delegatee pulls against the delegation
  • Then: a pull past expiry is refused with no drift grace ✓

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

sequenceDiagram
    participant p0 as bob
    participant p1 as Subscriptions
    p0->>p1: transferFixed
    activate p1
    note over p1: 🚩 custom program error  0x80
    p1-->>p0: ✗ 907cu
    deactivate p1

Authority

flowchart LR
    Subscriptions["Subscriptions"]:::program
    B1u4[("B1u4…")]:::state
    aliceATAMint[("alice/ATA(Mint)")]:::state
    bobATAMint[("bob/ATA(Mint)")]:::state
    bob(["bob"]):::signer
    Subscriptions -->|writes| B1u4
    Subscriptions -->|writes| aliceATAMint
    Subscriptions -->|writes| bobATAMint
    bob -->|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
    B1u4[("B1u4…")]:::state
    token["token"]:::program
    aliceATAMint[("alice/ATA(Mint)")]:::state
    bobATAMint[("bob/ATA(Mint)")]:::state
    system["system"]:::program
    bob[("bob")]:::state
    Subscriptions -->|owns| B1u4
    token -->|owns| aliceATAMint
    token -->|owns| bobATAMint
    system -->|owns| bob
    classDef program fill:#dae8fc,stroke:#6c8ebf;
    classDef signer fill:#d5e8d4,stroke:#82b366;
    classDef state fill:#ffe6cc,stroke:#d79b00;

Accounts

AccountSignerWritableOwner
B1u4…·Subscriptions
SA(alice, Mint)··Subscriptions
alice/ATA(Mint)·token
bob/ATA(Mint)·token
Mint··token
token··BPFL…
bobsystem
eventAuthority··system
Subscriptions··BPFL…
Call tree and logs
The drift tolerance does not extend a fixed transfer past expiry
bob (907cu)
└─ Subscriptions::transferFixed ✗ 907cu
Program De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44 invoke [1]
Program De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44 consumed 907 of 200000 compute units
Program De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44 failed: custom program error: 0x80

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};

// --- transferFixed ---
let program_id: Pubkey = pubkey!("De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44");
let delegation_pda: Pubkey = pubkey!("B1u4dvZ4DrFrUm3tm4K7zqN1Nb9fu2NuAFExDfyDocqg"); // 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!("5UJQzG1wu6euuhQRnsHbqjKC8DHmfS4jVNzghS3PL4Hx"); // 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 delegatee: Pubkey = pubkey!("6DAhWE3pSJQrSFRNjkGQznpUi7wWnqWf3FnRyZQqxbvu"); // bob: this scenario's value; supply your own
let event_authority = Pubkey::find_program_address(&[b"event_authority"], &program_id).0;
let ix = Instruction {
    program_id,
    accounts: vec![
        AccountMeta::new(delegation_pda, false), // delegationPda
        AccountMeta::new_readonly(subscription_authority, false), // subscriptionAuthority
        AccountMeta::new(delegator_ata, false), // delegatorAta
        AccountMeta::new(receiver_ata, false), // receiverAta
        AccountMeta::new_readonly(token_mint, false), // tokenMint
        AccountMeta::new_readonly(token_program, false), // tokenProgram
        AccountMeta::new_readonly(delegatee, true), // delegatee
        AccountMeta::new_readonly(event_authority, false), // eventAuthority
        AccountMeta::new_readonly(program_id, false), // selfProgram
    ],
    // discriminator ++ borsh(transferData: {amount: 10000000, delegator: 2rj1…, mint: Hhbh…})
    data: vec![0x04, 0x80, 0x96, 0x98, 0x00, 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 { getTransferFixedInstructionAsync } from "your-generated-client";

const ix = await getTransferFixedInstructionAsync({
  delegationPda: address("B1u4dvZ4DrFrUm3tm4K7zqN1Nb9fu2NuAFExDfyDocqg"),
  subscriptionAuthority: address("4RDKDQRnKtZC5ti4wunSAayArwxdZida5St595CiKJ7i"), // SA(alice, Mint)
  delegatorAta: address("3fVRopUs1BsF6RqteSmcyctBCx2YQ6Zkv7prsUz95VHY"),
  receiverAta: address("5UJQzG1wu6euuhQRnsHbqjKC8DHmfS4jVNzghS3PL4Hx"),
  tokenMint: address("HhbhtPWNt5Rd2gvrjhGCekommdNQwVLY6ZMp3aBv4xtG"), // Mint
  // tokenProgram defaults to the token program
  delegatee: delegateeSigner, // bob (a TransactionSigner)
  transferData: {
    amount: 10000000n,
    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() {
/// Fixed pull, 10s past expiry (inside the old 120s window): refused, no grace.
#[test]
fn drift_window_does_not_extend_fixed_transfer_past_expiry() {
    let mut story = Story::load(SO, IDL);
    let expiry_ts = story.now() + 100;
    let (alice, bob, delegation_pda, mint) = stage_fixed(&mut story, 50_000_000, expiry_ts);

    story.warp(110); // 10s past expiry, well inside the old 120s drift window
    let out = story.transfer_fixed(xfer(&bob, delegation_pda, alice.pubkey(), mint, 10_000_000));
    story.then_holds(
        "a pull past expiry is refused with no drift grace",
        failed_with(&out, DELEGATION_EXPIRED),
    );
    emit_scenario(
        &story,
        &out,
        "The drift tolerance does not extend a fixed transfer past expiry",
    );
}
}

A delegator creates a recurring delegation

Source: subscriptions/tests/test_create_recurring_delegation.rs L154

  • When: the user initializes a subscription authority
  • When: a recurring delegation is created
  • Then: the recurring delegation is created ✓
  • Then: recurringDelegation.header.delegator = 2rj1EEgg32bupe12mMYnPFaKQCPPyYBhKYmDFZ8cXxpT ✓
  • Then: recurringDelegation.header.delegatee = 111MqiH3tDg8KtkQYkCteems4APA9GgUsEFQavs8Vb ✓
  • Then: recurringDelegation.header.payer = 2rj1EEgg32bupe12mMYnPFaKQCPPyYBhKYmDFZ8cXxpT ✓
  • Then: recurringDelegation.header.discriminator = 3 ✓
  • Then: recurringDelegation.amountPerPeriod = 50000000 ✓
  • Then: recurringDelegation.periodLengthS = 86400 ✓
  • Then: recurringDelegation.expiryTs = 604800 ✓
  • Then: recurringDelegation.amountPulledInPeriod = 0 ✓
  • Then: recurringDelegation.currentPeriodStartTs = 0 ✓

Result: ✓ success — 6563 CU · claims 10/10 ✓

sequenceDiagram
    participant p0 as alice
    participant p1 as Subscriptions
    participant p2 as system
    p0->>p1: createRecurringDelegation
    activate p1
    p1->>p2: createAccount
    activate p2
    p2-->>p1: ✓
    deactivate p2
    p1-->>p0: ✓ 6563cu
    deactivate p1

Authority

flowchart LR
    Subscriptions["Subscriptions"]:::program
    alice(["alice"]):::signer
    4gFR(["4gFR…"]):::signer
    system["system"]:::program
    alice -->|signs| Subscriptions
    Subscriptions -->|writes| 4gFR
    alice -->|signs| system
    4gFR -->|signs| system
    classDef program fill:#dae8fc,stroke:#6c8ebf;
    classDef signer fill:#d5e8d4,stroke:#82b366;
    classDef state fill:#ffe6cc,stroke:#d79b00;

Ownership

flowchart LR
    system["system"]:::program
    alice[("alice")]:::state
    Subscriptions["Subscriptions"]:::program
    4gFR[("4gFR…")]:::state
    system -->|owns| alice
    Subscriptions -->|owns| 4gFR
    classDef program fill:#dae8fc,stroke:#6c8ebf;
    classDef signer fill:#d5e8d4,stroke:#82b366;
    classDef state fill:#ffe6cc,stroke:#d79b00;

Accounts

AccountSignerWritableOwner
alicesystem
SA(alice, Mint)··Subscriptions
4gFR…·Subscriptions
111M…··system
system··Nati…
Call tree and logs
A delegator creates a recurring delegation
alice (6563cu)
└─ Subscriptions::createRecurringDelegation ✓ 6563cu
   └─ system::createAccount ✓
Program De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44 invoke [1]
Program 11111111111111111111111111111111 invoke [2]
Program 11111111111111111111111111111111 success
Program De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44 consumed 6563 of 200000 compute units
Program De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44 success

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};

// --- createRecurringDelegation ---
let program_id: Pubkey = pubkey!("De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44");
let delegator: Pubkey = pubkey!("2rj1EEgg32bupe12mMYnPFaKQCPPyYBhKYmDFZ8cXxpT"); // alice: this scenario's value; supply your own
let subscription_authority: Pubkey = pubkey!("4RDKDQRnKtZC5ti4wunSAayArwxdZida5St595CiKJ7i"); // SA(alice, Mint): this scenario's value; supply your own
let delegation_account: Pubkey = pubkey!("4gFRG5Y1v18RTbbeTifYKT39Ckc116DqWMYo1RhWXiaw"); // supply your own
let delegatee: Pubkey = pubkey!("111MqiH3tDg8KtkQYkCteems4APA9GgUsEFQavs8Vb"); // supply your own
let system_program: Pubkey = pubkey!("11111111111111111111111111111111"); // the system program
let ix = Instruction {
    program_id,
    accounts: vec![
        AccountMeta::new(delegator, true), // delegator
        AccountMeta::new_readonly(subscription_authority, false), // subscriptionAuthority
        AccountMeta::new(delegation_account, false), // delegationAccount
        AccountMeta::new_readonly(delegatee, false), // delegatee
        AccountMeta::new_readonly(system_program, false), // systemProgram
    ],
    // discriminator ++ borsh(recurringDelegation: {nonce: 0, amountPerPeriod: 50000000, periodLengthS: 86400, startTs: 0, expiryTs: 604800, expectedSubscriptionAuthorityInitId: 0})
    data: vec![0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0xf0, 0xfa, 0x02, 0x00, 0x00, 0x00, 0x00, 0x80, 0x51, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0x3a, 0x09, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00],
};
}

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 { getCreateRecurringDelegationInstructionAsync } from "your-generated-client";

const ix = await getCreateRecurringDelegationInstructionAsync({
  delegator: delegatorSigner, // alice (a TransactionSigner)
  subscriptionAuthority: address("4RDKDQRnKtZC5ti4wunSAayArwxdZida5St595CiKJ7i"), // SA(alice, Mint)
  delegationAccount: address("4gFRG5Y1v18RTbbeTifYKT39Ckc116DqWMYo1RhWXiaw"),
  delegatee: address("111MqiH3tDg8KtkQYkCteems4APA9GgUsEFQavs8Vb"),
  // systemProgram defaults to the system program
  recurringDelegation: {
    nonce: 0n,
    amountPerPeriod: 50000000n,
    periodLengthS: 86400n,
    startTs: 0n,
    expiryTs: 604800n,
    expectedSubscriptionAuthorityInitId: 0n
  },
});
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 create_recurring_delegation() {
    let mut story = Story::load(SO, IDL);
    let alice = story.cast("alice");
    let amount_per_period: u64 = 50_000_000;
    let period_length_s: u64 = 86_400;
    let start_ts = story.now();
    let expiry_ts = start_ts + days(7) as i64;
    let nonce: u64 = 0;

    let mint = story.mint(&alice, MINT_DECIMALS);
    story.fund(&alice, &[(mint, 1_000_000)]);
    init(&mut story, &alice, mint);

    let delegatee = Pubkey::new_unique();
    let delegation_pda = story.delegation_pda(&alice.pubkey(), &mint, &delegatee, nonce);

    let out = story.create_recurring_delegation(CreateRecurringArgs {
        delegator: &alice,
        mint,
        delegatee,
        nonce,
        amount_per_period,
        period_length_s,
        start_ts,
        expiry_ts,
        sponsor: None,
        expected_init_id: None,
    });
    story.then_holds("the recurring delegation is created", out.success);

    let delegator =
        story.track_field::<Pubkey>("recurringDelegation", delegation_pda, "header.delegator");
    let delegatee_field =
        story.track_field::<Pubkey>("recurringDelegation", delegation_pda, "header.delegatee");
    let payer = story.track_field::<Pubkey>("recurringDelegation", delegation_pda, "header.payer");
    let discriminator = story.track_field::<u8>(
        "recurringDelegation",
        delegation_pda,
        "header.discriminator",
    );
    let amount_per_period_field =
        story.track_field::<u64>("recurringDelegation", delegation_pda, "amountPerPeriod");
    let period_length_field =
        story.track_field::<u64>("recurringDelegation", delegation_pda, "periodLengthS");
    let expiry_field = story.track_field::<i64>("recurringDelegation", delegation_pda, "expiryTs");
    let amount_pulled = story.track_field::<u64>(
        "recurringDelegation",
        delegation_pda,
        "amountPulledInPeriod",
    );
    let period_start = story.track_field::<i64>(
        "recurringDelegation",
        delegation_pda,
        "currentPeriodStartTs",
    );

    story.then_eq(&delegator, alice.pubkey());
    story.then_eq(&delegatee_field, delegatee);
    story.then_eq(&payer, alice.pubkey());
    story.then_eq(&discriminator, TAG_RECURRING_DELEGATION);
    story.then_eq(&amount_per_period_field, amount_per_period);
    story.then_eq(&period_length_field, period_length_s);
    story.then_eq(&expiry_field, expiry_ts);
    story.then_eq(&amount_pulled, 0u64);
    story.then_eq(&period_start, start_ts);
    emit_scenario(&story, &out, "A delegator creates a recurring delegation");
}
}

A delegatee pulls against a recurring delegation

Source: subscriptions/tests/test_transfer_recurring_delegation.rs L216

  • When: the user initializes a subscription authority
  • Then: initSubscriptionAuthority succeeds ✓
  • When: a recurring delegation is created
  • Then: createRecurringDelegation succeeds ✓
  • Then: Bob’s ATA starts empty ✓
  • When: the delegatee pulls against the delegation
  • Then: first pull succeeds ✓
  • Then: Bob received 10 tokens ✓
  • Then: 10 tokens pulled in the period ✓
  • Then: the period start is the delegation start ✓
  • When: the delegatee pulls against the delegation
  • Then: second pull succeeds ✓
  • Then: Bob received another 10 tokens ✓
  • Then: 20 tokens pulled in the period ✓
  • When: the delegatee pulls against the delegation
  • Then: third pull succeeds ✓
  • Then: Bob received a third 10 tokens ✓
  • Then: 30 tokens pulled in the period ✓

Result: ✓ success — 5665 CU · claims 13/13 ✓

sequenceDiagram
    participant p0 as bob
    participant p1 as Subscriptions
    participant p2 as token
    p0->>p1: transferRecurring
    activate p1
    p1->>p2: transferChecked
    activate p2
    p2-->>p1: ✓ 113cu
    deactivate p2
    p1->>p1: emit:recurringTransferEvent
    activate p1
    p1-->>p1: ✓ 137cu
    deactivate p1
    p1-->>p0: ✓ 5665cu
    deactivate p1

Authority

flowchart LR
    Subscriptions["Subscriptions"]:::program
    B1u4[("B1u4…")]:::state
    aliceATAMint[("alice/ATA(Mint)")]:::state
    bobATAMint[("bob/ATA(Mint)")]:::state
    bob(["bob"]):::signer
    token["token"]:::program
    SAaliceMint(["SA(alice, Mint)"]):::signer
    eventAuthority(["eventAuthority"]):::signer
    Subscriptions -->|writes| B1u4
    Subscriptions -->|writes| aliceATAMint
    Subscriptions -->|writes| bobATAMint
    bob -->|signs| Subscriptions
    token -->|writes| aliceATAMint
    token -->|writes| bobATAMint
    SAaliceMint -->|signs| token
    eventAuthority -->|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
    B1u4[("B1u4…")]:::state
    token["token"]:::program
    aliceATAMint[("alice/ATA(Mint)")]:::state
    bobATAMint[("bob/ATA(Mint)")]:::state
    system["system"]:::program
    bob[("bob")]:::state
    Subscriptions -->|owns| B1u4
    token -->|owns| aliceATAMint
    token -->|owns| bobATAMint
    system -->|owns| bob
    classDef program fill:#dae8fc,stroke:#6c8ebf;
    classDef signer fill:#d5e8d4,stroke:#82b366;
    classDef state fill:#ffe6cc,stroke:#d79b00;

Accounts

AccountSignerWritableOwner
B1u4…·Subscriptions
SA(alice, Mint)··Subscriptions
alice/ATA(Mint)·token
bob/ATA(Mint)·token
Mint··token
token··BPFL…
bobsystem
eventAuthority··system
Subscriptions··BPFL…
Call tree and logs
A delegatee pulls against a recurring delegation
bob (5665cu)
└─ Subscriptions::transferRecurring ✓ 5665cu
   ├─ token::transferChecked ✓ 113cu
   └─ Subscriptions::emit:recurringTransferEvent ✓ 137cu
Program De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44 invoke [1]
Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [2]
Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 113 of 195920 compute units
Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success
Program De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44 invoke [2]
Program De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44 consumed 137 of 194496 compute units
Program De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44 success
Program De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44 consumed 5665 of 200000 compute units
Program De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44 success

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};

// --- transferRecurring ---
let program_id: Pubkey = pubkey!("De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44");
let delegation_pda: Pubkey = pubkey!("B1u4dvZ4DrFrUm3tm4K7zqN1Nb9fu2NuAFExDfyDocqg"); // 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!("5UJQzG1wu6euuhQRnsHbqjKC8DHmfS4jVNzghS3PL4Hx"); // 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 delegatee: Pubkey = pubkey!("6DAhWE3pSJQrSFRNjkGQznpUi7wWnqWf3FnRyZQqxbvu"); // bob: this scenario's value; supply your own
let event_authority = Pubkey::find_program_address(&[b"event_authority"], &program_id).0;
let ix = Instruction {
    program_id,
    accounts: vec![
        AccountMeta::new(delegation_pda, false), // delegationPda
        AccountMeta::new_readonly(subscription_authority, false), // subscriptionAuthority
        AccountMeta::new(delegator_ata, false), // delegatorAta
        AccountMeta::new(receiver_ata, false), // receiverAta
        AccountMeta::new_readonly(token_mint, false), // tokenMint
        AccountMeta::new_readonly(token_program, false), // tokenProgram
        AccountMeta::new_readonly(delegatee, true), // delegatee
        AccountMeta::new_readonly(event_authority, false), // eventAuthority
        AccountMeta::new_readonly(program_id, false), // selfProgram
    ],
    // discriminator ++ borsh(transferData: {amount: 10000000, delegator: 2rj1…, mint: Hhbh…})
    data: vec![0x05, 0x80, 0x96, 0x98, 0x00, 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 { getTransferRecurringInstructionAsync } from "your-generated-client";

const ix = await getTransferRecurringInstructionAsync({
  delegationPda: address("B1u4dvZ4DrFrUm3tm4K7zqN1Nb9fu2NuAFExDfyDocqg"),
  subscriptionAuthority: address("4RDKDQRnKtZC5ti4wunSAayArwxdZida5St595CiKJ7i"), // SA(alice, Mint)
  delegatorAta: address("3fVRopUs1BsF6RqteSmcyctBCx2YQ6Zkv7prsUz95VHY"),
  receiverAta: address("5UJQzG1wu6euuhQRnsHbqjKC8DHmfS4jVNzghS3PL4Hx"),
  tokenMint: address("HhbhtPWNt5Rd2gvrjhGCekommdNQwVLY6ZMp3aBv4xtG"), // Mint
  // tokenProgram defaults to the token program
  delegatee: delegateeSigner, // bob (a TransactionSigner)
  transferData: {
    amount: 10000000n,
    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 test_recurring_transfer_success() {
    let mut story = Story::load(SO, IDL);
    let period_length_s = hours(1);
    let start_ts = story.now();
    let expiry_ts = story.now() + days(1) as i64;
    let (alice, bob, delegation_pda, mint, _, bob_ata) = setup_recurring_delegation(
        &mut story,
        50_000_000,
        period_length_s,
        start_ts,
        expiry_ts,
        0,
    );

    story.then_holds("Bob's ATA starts empty", balance(&story, &bob_ata) == 0);

    // Pull 1 in period 0.
    let out =
        story.transfer_recurring(xfer(&bob, delegation_pda, alice.pubkey(), mint, 10_000_000));
    story.then_holds("first pull succeeds", out.success);
    story.then_holds(
        "Bob received 10 tokens",
        balance(&story, &bob_ata) == 10_000_000,
    );
    story.then_holds(
        "10 tokens pulled in the period",
        pulled_in_period(&story, &delegation_pda) == 10_000_000,
    );
    story.then_holds(
        "the period start is the delegation start",
        period_start(&story, &delegation_pda) == start_ts,
    );

    // Pull 2, still within period 0.
    story.warp(minutes(15));
    let out =
        story.transfer_recurring(xfer(&bob, delegation_pda, alice.pubkey(), mint, 10_000_000));
    story.then_holds("second pull succeeds", out.success);
    story.then_holds(
        "Bob received another 10 tokens",
        balance(&story, &bob_ata) == 20_000_000,
    );
    story.then_holds(
        "20 tokens pulled in the period",
        pulled_in_period(&story, &delegation_pda) == 20_000_000,
    );

    // Pull 3, still within period 0.
    story.warp(minutes(15));
    let out =
        story.transfer_recurring(xfer(&bob, delegation_pda, alice.pubkey(), mint, 10_000_000));
    story.then_holds("third pull succeeds", out.success);
    story.then_holds(
        "Bob received a third 10 tokens",
        balance(&story, &bob_ata) == 30_000_000,
    );
    story.then_holds(
        "30 tokens pulled in the period",
        pulled_in_period(&story, &delegation_pda) == 30_000_000,
    );
    emit_scenario(
        &story,
        &out,
        "A delegatee pulls against a recurring delegation",
    );
}
}

A recurring pull before the delegation period begins is refused

Source: subscriptions/tests/audit_high_3_1_1.rs L108

  • When: the user initializes a subscription authority
  • When: a recurring delegation is created
  • Then: the recurring delegation is granted (it starts tomorrow) ✓
  • When: the delegatee pulls against the delegation
  • Then: the pre-start pull is refused ✓

Result: ✗ failed — 949 CU · claims 2/2 ✓

sequenceDiagram
    participant p0 as bob
    participant p1 as Subscriptions
    p0->>p1: transferRecurring
    activate p1
    note over p1: 🚩 custom program error  0x197
    p1-->>p0: ✗ 949cu
    deactivate p1

Authority

flowchart LR
    Subscriptions["Subscriptions"]:::program
    B1u4[("B1u4…")]:::state
    aliceATAMint[("alice/ATA(Mint)")]:::state
    bobATAMint[("bob/ATA(Mint)")]:::state
    bob(["bob"]):::signer
    Subscriptions -->|writes| B1u4
    Subscriptions -->|writes| aliceATAMint
    Subscriptions -->|writes| bobATAMint
    bob -->|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
    B1u4[("B1u4…")]:::state
    token["token"]:::program
    aliceATAMint[("alice/ATA(Mint)")]:::state
    bobATAMint[("bob/ATA(Mint)")]:::state
    system["system"]:::program
    bob[("bob")]:::state
    Subscriptions -->|owns| B1u4
    token -->|owns| aliceATAMint
    token -->|owns| bobATAMint
    system -->|owns| bob
    classDef program fill:#dae8fc,stroke:#6c8ebf;
    classDef signer fill:#d5e8d4,stroke:#82b366;
    classDef state fill:#ffe6cc,stroke:#d79b00;

Accounts

AccountSignerWritableOwner
B1u4…·Subscriptions
SA(alice, Mint)··Subscriptions
alice/ATA(Mint)·token
bob/ATA(Mint)·token
Mint··token
token··BPFL…
bobsystem
eventAuthority··system
Subscriptions··BPFL…
Call tree and logs
A recurring pull before the delegation period begins is refused
bob (949cu)
└─ Subscriptions::transferRecurring ✗ 949cu
Program De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44 invoke [1]
Program De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44 consumed 949 of 200000 compute units
Program De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44 failed: custom program error: 0x197

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};

// --- transferRecurring ---
let program_id: Pubkey = pubkey!("De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44");
let delegation_pda: Pubkey = pubkey!("B1u4dvZ4DrFrUm3tm4K7zqN1Nb9fu2NuAFExDfyDocqg"); // 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!("5UJQzG1wu6euuhQRnsHbqjKC8DHmfS4jVNzghS3PL4Hx"); // 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 delegatee: Pubkey = pubkey!("6DAhWE3pSJQrSFRNjkGQznpUi7wWnqWf3FnRyZQqxbvu"); // bob: this scenario's value; supply your own
let event_authority = Pubkey::find_program_address(&[b"event_authority"], &program_id).0;
let ix = Instruction {
    program_id,
    accounts: vec![
        AccountMeta::new(delegation_pda, false), // delegationPda
        AccountMeta::new_readonly(subscription_authority, false), // subscriptionAuthority
        AccountMeta::new(delegator_ata, false), // delegatorAta
        AccountMeta::new(receiver_ata, false), // receiverAta
        AccountMeta::new_readonly(token_mint, false), // tokenMint
        AccountMeta::new_readonly(token_program, false), // tokenProgram
        AccountMeta::new_readonly(delegatee, true), // delegatee
        AccountMeta::new_readonly(event_authority, false), // eventAuthority
        AccountMeta::new_readonly(program_id, false), // selfProgram
    ],
    // discriminator ++ borsh(transferData: {amount: 10000000, delegator: 2rj1…, mint: Hhbh…})
    data: vec![0x05, 0x80, 0x96, 0x98, 0x00, 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 { getTransferRecurringInstructionAsync } from "your-generated-client";

const ix = await getTransferRecurringInstructionAsync({
  delegationPda: address("B1u4dvZ4DrFrUm3tm4K7zqN1Nb9fu2NuAFExDfyDocqg"),
  subscriptionAuthority: address("4RDKDQRnKtZC5ti4wunSAayArwxdZida5St595CiKJ7i"), // SA(alice, Mint)
  delegatorAta: address("3fVRopUs1BsF6RqteSmcyctBCx2YQ6Zkv7prsUz95VHY"),
  receiverAta: address("5UJQzG1wu6euuhQRnsHbqjKC8DHmfS4jVNzghS3PL4Hx"),
  tokenMint: address("HhbhtPWNt5Rd2gvrjhGCekommdNQwVLY6ZMp3aBv4xtG"), // Mint
  // tokenProgram defaults to the token program
  delegatee: delegateeSigner, // bob (a TransactionSigner)
  transferData: {
    amount: 10000000n,
    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_1_recurring_pull_before_start_ts_is_refused() {
    let mut story = Story::load(SO, IDL);
    let alice = story.cast("alice"); // delegator
    let bob = story.cast("bob"); // delegatee (the attacker pulling early)
    let mint = story.mint(&alice, MINT_DECIMALS);
    story.fund(&alice, &[(mint, 100_000_000)]);
    story.fund(&bob, &[(mint, 0)]); // create Bob's receiver ATA
    story.init_authority(&alice, mint);

    const DAY: i64 = 86_400;
    const HOUR_S: u64 = 3_600;
    let now = story.now();
    let start_ts = now + DAY; // the delegation opens TOMORROW
    let expiry_ts = now + 7 * DAY;

    // 50 tokens/hour, but the delegation does not start until tomorrow.
    let authority = story.subscription_authority_pda(&alice.pubkey(), &mint);
    let create = story.create_recurring_delegation(CreateRecurringArgs {
        delegator: &alice,
        mint,
        delegatee: bob.pubkey(),
        nonce: 0,
        amount_per_period: 50_000_000,
        period_length_s: HOUR_S,
        start_ts,
        expiry_ts,
        sponsor: None,
        expected_init_id: None,
    });
    story.then_holds(
        "the recurring delegation is granted (it starts tomorrow)",
        create.success,
    );

    let delegation_pda =
        story.recurring_delegation_pda(&authority, &alice.pubkey(), &bob.pubkey(), 0);

    // The attack: Bob pulls 10 tokens NOW, a full day before start_ts.
    let alice_before = story.token_balance(&alice.ata(mint));
    let pull = story.transfer_recurring(TransferArgs {
        delegatee: &bob,
        delegation_pda,
        delegator: alice.pubkey(),
        mint,
        amount: 10_000_000,
        to: None,
        from: None,
    });

    story.then_holds("the pre-start pull is refused", !pull.success);
    emit_scenario(
        &story,
        &pull,
        "A recurring pull before the delegation period begins is refused",
    );
    story.then(
        "refusal carries custom error code for DelegationNotStarted",
        move |_s| refused_with(&pull, DELEGATION_NOT_STARTED),
    );
    story.then("no tokens move before the delegation starts", move |s| {
        s.token_balance(&alice.ata(mint)) == alice_before
    });
}
}

An authority revokes a delegation

Source: subscriptions/tests/test_revoke_delegation.rs L140

  • When: the user initializes a subscription authority
  • Then: initSubscriptionAuthority executes source-free ✓
  • When: a fixed delegation is created
  • Then: createFixedDelegation succeeds ✓
  • Then: fixedDelegation.header.discriminator = 2 ✓
  • When: the authority revokes the delegation
  • Then: revokeDelegation succeeds ✓
  • Then: the delegation account is closed ✓
  • Then: the rent flows back to Alice ✓
  • Then: alice’s rent increased by approximately the delegation rent ✓

Result: ✓ success — 289 CU · claims 7/7 ✓

sequenceDiagram
    participant p0 as alice
    participant p1 as Subscriptions
    p0->>p1: revokeDelegation
    activate p1
    p1-->>p0: ✓ 289cu
    deactivate p1

Authority

flowchart LR
    Subscriptions["Subscriptions"]:::program
    alice(["alice"]):::signer
    94BV[("94BV…")]:::state
    alice -->|signs| Subscriptions
    Subscriptions -->|writes| 94BV
    classDef program fill:#dae8fc,stroke:#6c8ebf;
    classDef signer fill:#d5e8d4,stroke:#82b366;
    classDef state fill:#ffe6cc,stroke:#d79b00;

Ownership

flowchart LR
    system["system"]:::program
    alice[("alice")]:::state
    94BV[("94BV…")]:::state
    system -->|owns| alice
    system -->|owns| 94BV
    classDef program fill:#dae8fc,stroke:#6c8ebf;
    classDef signer fill:#d5e8d4,stroke:#82b366;
    classDef state fill:#ffe6cc,stroke:#d79b00;

Accounts

AccountSignerWritableOwner
alicesystem
94BV…·system
Call tree and logs
An authority revokes a delegation
alice (289cu)
└─ Subscriptions::revokeDelegation ✓ 289cu
Program De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44 invoke [1]
Program De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44 consumed 289 of 200000 compute units
Program De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44 success

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};

// --- revokeDelegation ---
let program_id: Pubkey = pubkey!("De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44");
let authority: Pubkey = pubkey!("2rj1EEgg32bupe12mMYnPFaKQCPPyYBhKYmDFZ8cXxpT"); // alice: this scenario's value; supply your own
let delegation_account: Pubkey = pubkey!("94BV622mohWYRCczHM2JuxzLx2dLLzFvqcN6DNfJqdek"); // supply your own
let ix = Instruction {
    program_id,
    accounts: vec![
        AccountMeta::new(authority, true), // authority
        AccountMeta::new(delegation_account, false), // delegationAccount
    ],
    // discriminator ++ borsh()
    data: vec![0x03],
};
}

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 { getRevokeDelegationInstructionAsync } from "your-generated-client";

const ix = await getRevokeDelegationInstructionAsync({
  authority: authoritySigner, // alice (a TransactionSigner)
  delegationAccount: address("94BV622mohWYRCczHM2JuxzLx2dLLzFvqcN6DNfJqdek"),
});
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 revoke_fixed_delegation() {
    let mut story = Story::load(SO, IDL);
    let alice = story.cast("alice");
    let mint = story.mint(&alice, MINT_DECIMALS);
    story.fund(&alice, &[(mint, 1_000_000)]);
    init(&mut story, &alice, mint);

    let delegatee = Pubkey::new_unique();
    let expiry = story.now() + 1000;
    let delegation_pda = fixed(&mut story, &alice, mint, delegatee, 0, 100, expiry, None);

    let delegation_rent = lamports(&story, &delegation_pda);
    let discriminator =
        story.track_field::<u8>("fixedDelegation", delegation_pda, "header.discriminator");
    story.then_eq(&discriminator, TAG_FIXED_DELEGATION);

    let alice_before = lamports(&story, &alice.pubkey());
    let out = story.revoke_delegation(RevokeArgs {
        signer: &alice,
        delegation_pda,
        receiver: None,
    });
    story.then_holds("revokeDelegation succeeds", out.success);

    story.then_holds(
        "the delegation account is closed",
        is_gone(&story, &delegation_pda),
    );
    let alice_after = lamports(&story, &alice.pubkey());
    story.then_holds("the rent flows back to Alice", alice_after > alice_before);
    story.then_holds(
        "alice's rent increased by approximately the delegation rent",
        alice_after >= alice_before + delegation_rent - 10_000,
    );
    emit_scenario(&story, &out, "An authority revokes a delegation");
}
}

An abandoned delegation is revoked

Source: subscriptions/tests/test_revoke_abandoned_delegation.rs L136

  • When: the user initializes a subscription authority
  • When: a fixed delegation is created
  • When: the user closes the subscription authority
  • When: an abandoned delegation is revoked
  • Then: the abandoned delegation is revoked ✓
  • Then: the delegation account is gone ✓
  • Then: the sponsor recovered the rent ✓

Result: ✓ success — 305 CU · claims 3/3 ✓

sequenceDiagram
    participant p0 as sponsor
    participant p1 as Subscriptions
    p0->>p1: revokeAbandonedDelegation
    activate p1
    p1-->>p0: ✓ 305cu
    deactivate p1

Authority

flowchart LR
    Subscriptions["Subscriptions"]:::program
    sponsor(["sponsor"]):::signer
    7JW9[("7JW9…")]:::state
    sponsor -->|signs| Subscriptions
    Subscriptions -->|writes| 7JW9
    classDef program fill:#dae8fc,stroke:#6c8ebf;
    classDef signer fill:#d5e8d4,stroke:#82b366;
    classDef state fill:#ffe6cc,stroke:#d79b00;

Ownership

flowchart LR
    system["system"]:::program
    sponsor[("sponsor")]:::state
    7JW9[("7JW9…")]:::state
    system -->|owns| sponsor
    system -->|owns| 7JW9
    classDef program fill:#dae8fc,stroke:#6c8ebf;
    classDef signer fill:#d5e8d4,stroke:#82b366;
    classDef state fill:#ffe6cc,stroke:#d79b00;

Accounts

AccountSignerWritableOwner
sponsorsystem
7JW9…·system
SA(alice, Mint)··system
Call tree and logs
An abandoned delegation is revoked
sponsor (305cu)
└─ Subscriptions::revokeAbandonedDelegation ✓ 305cu
Program De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44 invoke [1]
Program De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44 consumed 305 of 200000 compute units
Program De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44 success

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};

// --- revokeAbandonedDelegation ---
let program_id: Pubkey = pubkey!("De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44");
let payer: Pubkey = pubkey!("EgNLJSfXpchFPfE4Xd6XxWBzjhYfmtPAcUQDFUZ6vWog"); // sponsor: this scenario's value; supply your own
let delegation_account: Pubkey = pubkey!("7JW9nfbPZbx3UQUbDA69x9D9Tytmmh23p7wYJ6Eq9RWM"); // supply your own
let subscription_authority: Pubkey = pubkey!("4RDKDQRnKtZC5ti4wunSAayArwxdZida5St595CiKJ7i"); // SA(alice, Mint): this scenario's value; supply your own
let ix = Instruction {
    program_id,
    accounts: vec![
        AccountMeta::new(payer, true), // payer
        AccountMeta::new(delegation_account, false), // delegationAccount
        AccountMeta::new_readonly(subscription_authority, false), // subscriptionAuthority
    ],
    // discriminator ++ borsh()
    data: vec![0x0f],
};
}

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 { getRevokeAbandonedDelegationInstructionAsync } from "your-generated-client";

const ix = await getRevokeAbandonedDelegationInstructionAsync({
  payer: payerSigner, // sponsor (a TransactionSigner)
  delegationAccount: address("7JW9nfbPZbx3UQUbDA69x9D9Tytmmh23p7wYJ6Eq9RWM"),
  subscriptionAuthority: address("4RDKDQRnKtZC5ti4wunSAayArwxdZida5St595CiKJ7i"), // SA(alice, 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 sponsor_recovers_no_expiry_fixed_delegation_after_authority_closed() {
    let mut story = Story::load(SO, IDL);
    let alice = story.cast("alice");
    let sponsor = story.cast("sponsor");
    let delegatee = Pubkey::new_unique();

    let mint = story.mint(&alice, MINT_DECIMALS);
    story.fund(&alice, &[(mint, 1_000_000)]);
    init(&mut story, &alice, mint);

    let nonce: u64 = 0;
    let delegation_pda = story.delegation_pda(&alice.pubkey(), &mint, &delegatee, nonce);
    assert!(
        story
            .create_fixed_delegation(CreateFixedArgs {
                delegator: &alice,
                mint,
                delegatee,
                nonce,
                amount: 100,
                expiry_ts: NO_EXPIRY,
                sponsor: Some(&sponsor),
                expected_init_id: None,
                delegation_override: None,
            })
            .success
    );

    let delegation_rent = lamports(&story, &delegation_pda);
    // Alice closes her authority, abandoning the delegation.
    close(&mut story, &alice, mint);

    let sponsor_pubkey = sponsor.pubkey();
    let sponsor_before = lamports(&story, &sponsor_pubkey);
    let out = story.revoke_abandoned_delegation(RevokeAbandonedArgs {
        payer: &sponsor,
        delegator: alice.pubkey(),
        mint,
        delegatee,
        nonce,
        authority: None,
    });
    story.then_holds("the abandoned delegation is revoked", out.success);

    story.then_holds(
        "the delegation account is gone",
        is_gone(&story, &delegation_pda),
    );
    story.then("the sponsor recovered the rent", move |s| {
        let sponsor_after = lamports(s, &sponsor_pubkey);
        sponsor_after >= sponsor_before + delegation_rent - 10_000
    });
    emit_scenario(&story, &out, "An abandoned delegation is revoked");
}
}

Reference

These pages are generated from the program’s Codama IDL: one per instruction, with its accounts and arguments. Nothing here was written by hand.

This analysis is experimental, and our propulsion deserves some of the credit. The Infinite Improbability Drive from the Heart of Gold was unavailable, so we are running on a drive salvaged from the ship of fools, a vessel crewed largely by telephone sanitizers and marketing executives; this may account for a certain amount of the improbability below. Everything here was reconstructed by machine from the IDL rather than declared by a human, so it arrives with the cheerful confidence of a depressed robot who has worked out the odds and would rather not go on about it. Anything surprising is worth checking against the executed examples nested under each instruction, which have at least had the decency to actually run.

Don’t Panic.

initSubscriptionAuthority

Accounts

AccountSignerWritableSupplied
ownercaller
subscriptionAuthority·default (PDA)
tokenMint··caller
userAta·caller
systemProgram··default (fixed key)
tokenProgram··caller
payercaller

createFixedDelegation

Accounts

AccountSignerWritableSupplied
delegatorcaller
subscriptionAuthority··caller
delegationAccount·caller
delegatee··caller
systemProgram··default (fixed key)
payercaller

Arguments

ArgumentType
fixedDelegationcreateFixedDelegationData

createRecurringDelegation

Accounts

AccountSignerWritableSupplied
delegatorcaller
subscriptionAuthority··caller
delegationAccount·caller
delegatee··caller
systemProgram··default (fixed key)
payercaller

Arguments

ArgumentType
recurringDelegationcreateRecurringDelegationData

revokeDelegation

Accounts

AccountSignerWritableSupplied
authoritycaller
delegationAccount·caller

transferFixed

Accounts

AccountSignerWritableSupplied
delegationPda·caller
subscriptionAuthority··caller
delegatorAta·caller
receiverAta·caller
tokenMint··caller
tokenProgram··caller
delegatee·caller
eventAuthority··default (PDA)
selfProgram··default (fixed key)

Arguments

ArgumentType
transferDatatransferData

transferRecurring

Accounts

AccountSignerWritableSupplied
delegationPda·caller
subscriptionAuthority··caller
delegatorAta·caller
receiverAta·caller
tokenMint··caller
tokenProgram··caller
delegatee·caller
eventAuthority··default (PDA)
selfProgram··default (fixed key)

Arguments

ArgumentType
transferDatatransferData

closeSubscriptionAuthority

Accounts

AccountSignerWritableSupplied
usercaller
subscriptionAuthority·caller
receiver·caller

createPlan

Accounts

AccountSignerWritableSupplied
merchantcaller
planPda·caller
tokenMint··caller
systemProgram··default (fixed key)
tokenProgram··default (fixed key)

Arguments

ArgumentType
planDataplanData

updatePlan

Accounts

AccountSignerWritableSupplied
owner·caller
planPda·caller
eventAuthority··default (PDA)
selfProgram··default (fixed key)

Arguments

ArgumentType
updatePlanDataupdatePlanData

deletePlan

Accounts

AccountSignerWritableSupplied
ownercaller
planPda·caller

transferSubscription

Accounts

AccountSignerWritableSupplied
subscriptionPda·caller
planPda··caller
subscriptionAuthority··caller
delegatorAta·caller
receiverAta·caller
caller·caller
tokenMint··caller
tokenProgram··caller
eventAuthority··default (PDA)
selfProgram··default (fixed key)

Arguments

ArgumentType
transferDatatransferData

subscribe

Accounts

AccountSignerWritableSupplied
subscribercaller
merchant··caller
planPda··caller
subscriptionPda·default (PDA)
subscriptionAuthorityPda··caller
systemProgram··default (fixed key)
eventAuthority··default (PDA)
selfProgram··default (fixed key)
payercaller

Arguments

ArgumentType
subscribeDatasubscribeData

cancelSubscription

Accounts

AccountSignerWritableSupplied
subscriber·caller
planPda··caller
subscriptionPda·default (PDA)
eventAuthority··default (PDA)
selfProgram··default (fixed key)

resumeSubscription

Accounts

AccountSignerWritableSupplied
subscriber·caller
planPda··caller
subscriptionPda·default (PDA)
subscriptionAuthority··caller
eventAuthority··default (PDA)
selfProgram··default (fixed key)

revokeSubscriptionAuthority

Accounts

AccountSignerWritableSupplied
usercaller
userAta·caller
tokenMint··caller
tokenProgram··caller
subscriptionAuthority·default (PDA)
receiver·caller

revokeAbandonedDelegation

Accounts

AccountSignerWritableSupplied
payercaller
delegationAccount·caller
subscriptionAuthority··caller

revokeAbandonedSubscription

Accounts

AccountSignerWritableSupplied
payercaller
subscriptionAccount·caller
subscriptionAuthority··caller
planPda··caller

Errors

CodeNameMessage
100notSignerAccount must be a signer
101invalidAddressInvalid account address
102invalidEscrowPdaInvalid escrow PDA derivation
103invalidSubscriptionAuthorityPdaInvalid subscription-authority PDA derivation
104notSystemProgramExpected system program
105invalidTokenProgramToken Program does not match other accounts
106invalidToken2022MintAccountDataInvalid Token-2022 mint account data
107invalidToken2022TokenAccountDataInvalid Token-2022 token account data
108invalidAssociatedTokenAccountDerivedAddressInvalid associated token account address
109invalidTokenSplMintAccountDataInvalid SPL Token mint account data
110invalidTokenSplTokenAccountDataInvalid SPL Token account data
111invalidAccountDataInvalid account data
112invalidInstructionDataInvalid instruction data
113notEnoughAccountKeysNot enough account keys provided
114invalidInstructionInvalid instruction
115arithmeticOverflowArithmetic Overflow
116arithmeticUnderflowArithmetic Underflow
117invalidAccountDiscriminatorInvalid account discriminator
118mintHasConfidentialTransferMint has ConfidentialTransfer extension
119mintHasNonTransferableMint has NonTransferable extension
120mintHasPermanentDelegateMint has PermanentDelegate extension
121mintHasTransferHookMint has TransferHook extension
122mintHasTransferFeeMint has TransferFee extension
123mintHasMintCloseAuthorityMint has MintCloseAuthority extension
124mintHasPausableMint has Pausable extension
125mintMismatchToken mint mismatch
126invalidDelegatePdaInvalid delegation PDA derivation
127invalidHeaderDataInvalid header data
128delegationExpiredDelegation has expired
129invalidAmountInvalid amount specified
130unauthorizedCaller not authorized for this action
131accountNotWritableAccount must be writable
132ataOwnerMismatchToken account owner does not match expected
133delegationVersionMismatchDelegation header version is not compatible
134migrationRequiredAccount requires explicit migration
135delegationAlreadyExistsDelegation account already exists
136staleSubscriptionAuthorityDelegation init_id does not match current SubscriptionAuthority
137transferHookTooManyAccountsToo many transfer hook accounts provided
300amountExceedsLimitTransfer amount exceeds delegation limit
301fixedDelegationExpiryInPastExpiry time specified is less than current time
302fixedDelegationAmountZerozero amount specified
400amountExceedsPeriodLimitTransfer amount exceeds period limit
401periodNotElapsedPeriod has not elapsed yet
402invalidPeriodLengthInvalid Period length
403invalidPayerDataPayer provided does not match delegation
404recurringDelegationStartTimeInPastPast start time specified
405recurringDelegationStartTimeGreaterThanExpirystart time specified is greater than expiry
406recurringDelegationAmountZerozero amount specified
407delegationNotStartedDelegation period has not started yet
408recurringDelegationStartOnLandingRequiresExpirystart_ts of 0 (start on landing) requires a non-zero expiry
500planSunsetPlan is in sunset status
501planExpiredPlan has expired
502invalidPlanPdaInvalid Plan PDA derivation
503invalidSubscriptionPdaInvalid subscription PDA derivation
504notPlanOwnerCaller is not the plan owner
505subscriptionPlanMismatchSubscription does not belong to this plan
506unauthorizedDestinationDestination not in plan whitelist
507invalidNumDestinationsNo valid destinations provided
508subscriptionCancelledSubscription cancelled and past valid period
509subscriptionAlreadyCancelledSubscription already cancelled
510subscriptionNotCancelledSubscription is not cancelled
511invalidEndTsEnd timestamp must be zero or in the future
512invalidPlanStatusInvalid plan status value
513planImmutableAfterSunsetPlan cannot be updated after sunset
514sunsetRequiresEndTsSunset requires a non-zero end timestamp
515planNotExpiredPlan must be expired to delete
516planClosedPlan account has been closed
517alreadySubscribedAlready subscribed to this plan
518planAlreadyExistsPlan account already exists
519planTermsMismatchSubscription plan terms do not match the current plan
520planEndTsCannotExtendA finite plan end timestamp can only be shortened, not removed or extended
600invalidEventAuthorityInvalid event authority PDA
601invalidEventDataInvalid event data
602invalidEventTagInvalid event tag prefix
603invalidEventDiscriminatorUnknown event discriminator
604invalidSelfProgramSelf program account does not match this program