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
| Account | Signer | Writable | Owner |
|---|---|---|---|
| alice | ✓ | ✓ | system |
| 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
| Account | Signer | Writable | Owner |
|---|---|---|---|
| alice | ✓ | ✓ | system |
| 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
| Account | Signer | Writable | Owner |
|---|---|---|---|
| alice | ✓ | ✓ | system |
| 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
| Account | Signer | Writable | Owner |
|---|---|---|---|
| alice | ✓ | ✓ | system |
| 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
| Account | Signer | Writable | Owner |
|---|---|---|---|
| merchant | ✓ | ✓ | system |
| 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
| Account | Signer | Writable | Owner |
|---|---|---|---|
| merchant | ✓ | ✓ | system |
| 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
| Account | Signer | Writable | Owner |
|---|---|---|---|
| merchant | ✓ | ✓ | system |
| 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
| Account | Signer | Writable | Owner |
|---|---|---|---|
| alice | ✓ | ✓ | system |
| 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
| Account | Signer | Writable | Owner |
|---|---|---|---|
| alice | ✓ | ✓ | system |
| 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
| Account | Signer | Writable | Owner |
|---|---|---|---|
| alice | ✓ | ✓ | system |
| 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
| Account | Signer | Writable | Owner |
|---|---|---|---|
| subscription(plan(merchant, 1), alice) | · | ✓ | Subscriptions |
| plan(merchant, 1) | · | · | Subscriptions |
| SA(alice, Mint) | · | · | Subscriptions |
| alice/ATA(Mint) | · | ✓ | token |
| merchant/ATA(Mint) | · | ✓ | token |
| merchant | ✓ | ✓ | system |
| 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
| Account | Signer | Writable | Owner |
|---|---|---|---|
| subscription(plan(merchant, 1), alice) | · | ✓ | Subscriptions |
| plan(merchant, 1) | · | · | Subscriptions |
| SA(alice, Mint) | · | · | Subscriptions |
| alice/ATA(Mint) | · | ✓ | token |
| merchant/ATA(Mint) | · | ✓ | token |
| merchant | ✓ | ✓ | system |
| 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
| Account | Signer | Writable | Owner |
|---|---|---|---|
| Subscription | · | ✓ | Subscriptions |
| Plan | · | · | Subscriptions |
| SA(alice, USDC mint) | · | · | Subscriptions |
| alice/ATA(USDC mint) | · | ✓ | token |
| merchant/ATA(USDC mint) | · | ✓ | token |
| merchant | ✓ | ✓ | system |
| 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
| Account | Signer | Writable | Owner |
|---|---|---|---|
| alice | ✓ | ✓ | system |
| 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
| Account | Signer | Writable | Owner |
|---|---|---|---|
| B1u4… | · | ✓ | Subscriptions |
| SA(alice, Mint) | · | · | Subscriptions |
| alice/ATA(Mint) | · | ✓ | token |
| bob/ATA(Mint) | · | ✓ | token |
| Mint | · | · | token |
| token | · | · | BPFL… |
| bob | ✓ | ✓ | system |
| 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
| Account | Signer | Writable | Owner |
|---|---|---|---|
| B1u4… | · | ✓ | Subscriptions |
| SA(alice, Mint) | · | · | Subscriptions |
| alice/ATA(Mint) | · | ✓ | token |
| bob/ATA(Mint) | · | ✓ | token |
| Mint | · | · | token |
| token | · | · | BPFL… |
| bob | ✓ | ✓ | system |
| 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
| Account | Signer | Writable | Owner |
|---|---|---|---|
| alice | ✓ | ✓ | system |
| 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
| Account | Signer | Writable | Owner |
|---|---|---|---|
| B1u4… | · | ✓ | Subscriptions |
| SA(alice, Mint) | · | · | Subscriptions |
| alice/ATA(Mint) | · | ✓ | token |
| bob/ATA(Mint) | · | ✓ | token |
| Mint | · | · | token |
| token | · | · | BPFL… |
| bob | ✓ | ✓ | system |
| 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
| Account | Signer | Writable | Owner |
|---|---|---|---|
| B1u4… | · | ✓ | Subscriptions |
| SA(alice, Mint) | · | · | Subscriptions |
| alice/ATA(Mint) | · | ✓ | token |
| bob/ATA(Mint) | · | ✓ | token |
| Mint | · | · | token |
| token | · | · | BPFL… |
| bob | ✓ | ✓ | system |
| 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
| Account | Signer | Writable | Owner |
|---|---|---|---|
| alice | ✓ | ✓ | system |
| 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
| Account | Signer | Writable | Owner |
|---|---|---|---|
| sponsor | ✓ | ✓ | system |
| 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
| Account | Signer | Writable | Supplied |
|---|---|---|---|
| owner | ✓ | ✓ | caller |
| subscriptionAuthority | · | ✓ | default (PDA) |
| tokenMint | · | · | caller |
| userAta | · | ✓ | caller |
| systemProgram | · | · | default (fixed key) |
| tokenProgram | · | · | caller |
| payer | ✓ | ✓ | caller |
createFixedDelegation
Accounts
| Account | Signer | Writable | Supplied |
|---|---|---|---|
| delegator | ✓ | ✓ | caller |
| subscriptionAuthority | · | · | caller |
| delegationAccount | · | ✓ | caller |
| delegatee | · | · | caller |
| systemProgram | · | · | default (fixed key) |
| payer | ✓ | ✓ | caller |
Arguments
| Argument | Type |
|---|---|
| fixedDelegation | createFixedDelegationData |
createRecurringDelegation
Accounts
| Account | Signer | Writable | Supplied |
|---|---|---|---|
| delegator | ✓ | ✓ | caller |
| subscriptionAuthority | · | · | caller |
| delegationAccount | · | ✓ | caller |
| delegatee | · | · | caller |
| systemProgram | · | · | default (fixed key) |
| payer | ✓ | ✓ | caller |
Arguments
| Argument | Type |
|---|---|
| recurringDelegation | createRecurringDelegationData |
revokeDelegation
Accounts
| Account | Signer | Writable | Supplied |
|---|---|---|---|
| authority | ✓ | ✓ | caller |
| delegationAccount | · | ✓ | caller |
transferFixed
Accounts
| Account | Signer | Writable | Supplied |
|---|---|---|---|
| delegationPda | · | ✓ | caller |
| subscriptionAuthority | · | · | caller |
| delegatorAta | · | ✓ | caller |
| receiverAta | · | ✓ | caller |
| tokenMint | · | · | caller |
| tokenProgram | · | · | caller |
| delegatee | ✓ | · | caller |
| eventAuthority | · | · | default (PDA) |
| selfProgram | · | · | default (fixed key) |
Arguments
| Argument | Type |
|---|---|
| transferData | transferData |
transferRecurring
Accounts
| Account | Signer | Writable | Supplied |
|---|---|---|---|
| delegationPda | · | ✓ | caller |
| subscriptionAuthority | · | · | caller |
| delegatorAta | · | ✓ | caller |
| receiverAta | · | ✓ | caller |
| tokenMint | · | · | caller |
| tokenProgram | · | · | caller |
| delegatee | ✓ | · | caller |
| eventAuthority | · | · | default (PDA) |
| selfProgram | · | · | default (fixed key) |
Arguments
| Argument | Type |
|---|---|
| transferData | transferData |
closeSubscriptionAuthority
Accounts
| Account | Signer | Writable | Supplied |
|---|---|---|---|
| user | ✓ | ✓ | caller |
| subscriptionAuthority | · | ✓ | caller |
| receiver | · | ✓ | caller |
createPlan
Accounts
| Account | Signer | Writable | Supplied |
|---|---|---|---|
| merchant | ✓ | ✓ | caller |
| planPda | · | ✓ | caller |
| tokenMint | · | · | caller |
| systemProgram | · | · | default (fixed key) |
| tokenProgram | · | · | default (fixed key) |
Arguments
| Argument | Type |
|---|---|
| planData | planData |
updatePlan
Accounts
| Account | Signer | Writable | Supplied |
|---|---|---|---|
| owner | ✓ | · | caller |
| planPda | · | ✓ | caller |
| eventAuthority | · | · | default (PDA) |
| selfProgram | · | · | default (fixed key) |
Arguments
| Argument | Type |
|---|---|
| updatePlanData | updatePlanData |
deletePlan
Accounts
| Account | Signer | Writable | Supplied |
|---|---|---|---|
| owner | ✓ | ✓ | caller |
| planPda | · | ✓ | caller |
transferSubscription
Accounts
| Account | Signer | Writable | Supplied |
|---|---|---|---|
| subscriptionPda | · | ✓ | caller |
| planPda | · | · | caller |
| subscriptionAuthority | · | · | caller |
| delegatorAta | · | ✓ | caller |
| receiverAta | · | ✓ | caller |
| caller | ✓ | · | caller |
| tokenMint | · | · | caller |
| tokenProgram | · | · | caller |
| eventAuthority | · | · | default (PDA) |
| selfProgram | · | · | default (fixed key) |
Arguments
| Argument | Type |
|---|---|
| transferData | transferData |
subscribe
Accounts
| Account | Signer | Writable | Supplied |
|---|---|---|---|
| subscriber | ✓ | ✓ | caller |
| merchant | · | · | caller |
| planPda | · | · | caller |
| subscriptionPda | · | ✓ | default (PDA) |
| subscriptionAuthorityPda | · | · | caller |
| systemProgram | · | · | default (fixed key) |
| eventAuthority | · | · | default (PDA) |
| selfProgram | · | · | default (fixed key) |
| payer | ✓ | ✓ | caller |
Arguments
| Argument | Type |
|---|---|
| subscribeData | subscribeData |
cancelSubscription
Accounts
| Account | Signer | Writable | Supplied |
|---|---|---|---|
| subscriber | ✓ | · | caller |
| planPda | · | · | caller |
| subscriptionPda | · | ✓ | default (PDA) |
| eventAuthority | · | · | default (PDA) |
| selfProgram | · | · | default (fixed key) |
resumeSubscription
Accounts
| Account | Signer | Writable | Supplied |
|---|---|---|---|
| subscriber | ✓ | · | caller |
| planPda | · | · | caller |
| subscriptionPda | · | ✓ | default (PDA) |
| subscriptionAuthority | · | · | caller |
| eventAuthority | · | · | default (PDA) |
| selfProgram | · | · | default (fixed key) |
revokeSubscriptionAuthority
Accounts
| Account | Signer | Writable | Supplied |
|---|---|---|---|
| user | ✓ | ✓ | caller |
| userAta | · | ✓ | caller |
| tokenMint | · | · | caller |
| tokenProgram | · | · | caller |
| subscriptionAuthority | · | ✓ | default (PDA) |
| receiver | · | ✓ | caller |
revokeAbandonedDelegation
Accounts
| Account | Signer | Writable | Supplied |
|---|---|---|---|
| payer | ✓ | ✓ | caller |
| delegationAccount | · | ✓ | caller |
| subscriptionAuthority | · | · | caller |
revokeAbandonedSubscription
Accounts
| Account | Signer | Writable | Supplied |
|---|---|---|---|
| payer | ✓ | ✓ | caller |
| subscriptionAccount | · | ✓ | caller |
| subscriptionAuthority | · | · | caller |
| planPda | · | · | caller |
Errors
| Code | Name | Message |
|---|---|---|
| 100 | notSigner | Account must be a signer |
| 101 | invalidAddress | Invalid account address |
| 102 | invalidEscrowPda | Invalid escrow PDA derivation |
| 103 | invalidSubscriptionAuthorityPda | Invalid subscription-authority PDA derivation |
| 104 | notSystemProgram | Expected system program |
| 105 | invalidTokenProgram | Token Program does not match other accounts |
| 106 | invalidToken2022MintAccountData | Invalid Token-2022 mint account data |
| 107 | invalidToken2022TokenAccountData | Invalid Token-2022 token account data |
| 108 | invalidAssociatedTokenAccountDerivedAddress | Invalid associated token account address |
| 109 | invalidTokenSplMintAccountData | Invalid SPL Token mint account data |
| 110 | invalidTokenSplTokenAccountData | Invalid SPL Token account data |
| 111 | invalidAccountData | Invalid account data |
| 112 | invalidInstructionData | Invalid instruction data |
| 113 | notEnoughAccountKeys | Not enough account keys provided |
| 114 | invalidInstruction | Invalid instruction |
| 115 | arithmeticOverflow | Arithmetic Overflow |
| 116 | arithmeticUnderflow | Arithmetic Underflow |
| 117 | invalidAccountDiscriminator | Invalid account discriminator |
| 118 | mintHasConfidentialTransfer | Mint has ConfidentialTransfer extension |
| 119 | mintHasNonTransferable | Mint has NonTransferable extension |
| 120 | mintHasPermanentDelegate | Mint has PermanentDelegate extension |
| 121 | mintHasTransferHook | Mint has TransferHook extension |
| 122 | mintHasTransferFee | Mint has TransferFee extension |
| 123 | mintHasMintCloseAuthority | Mint has MintCloseAuthority extension |
| 124 | mintHasPausable | Mint has Pausable extension |
| 125 | mintMismatch | Token mint mismatch |
| 126 | invalidDelegatePda | Invalid delegation PDA derivation |
| 127 | invalidHeaderData | Invalid header data |
| 128 | delegationExpired | Delegation has expired |
| 129 | invalidAmount | Invalid amount specified |
| 130 | unauthorized | Caller not authorized for this action |
| 131 | accountNotWritable | Account must be writable |
| 132 | ataOwnerMismatch | Token account owner does not match expected |
| 133 | delegationVersionMismatch | Delegation header version is not compatible |
| 134 | migrationRequired | Account requires explicit migration |
| 135 | delegationAlreadyExists | Delegation account already exists |
| 136 | staleSubscriptionAuthority | Delegation init_id does not match current SubscriptionAuthority |
| 137 | transferHookTooManyAccounts | Too many transfer hook accounts provided |
| 300 | amountExceedsLimit | Transfer amount exceeds delegation limit |
| 301 | fixedDelegationExpiryInPast | Expiry time specified is less than current time |
| 302 | fixedDelegationAmountZero | zero amount specified |
| 400 | amountExceedsPeriodLimit | Transfer amount exceeds period limit |
| 401 | periodNotElapsed | Period has not elapsed yet |
| 402 | invalidPeriodLength | Invalid Period length |
| 403 | invalidPayerData | Payer provided does not match delegation |
| 404 | recurringDelegationStartTimeInPast | Past start time specified |
| 405 | recurringDelegationStartTimeGreaterThanExpiry | start time specified is greater than expiry |
| 406 | recurringDelegationAmountZero | zero amount specified |
| 407 | delegationNotStarted | Delegation period has not started yet |
| 408 | recurringDelegationStartOnLandingRequiresExpiry | start_ts of 0 (start on landing) requires a non-zero expiry |
| 500 | planSunset | Plan is in sunset status |
| 501 | planExpired | Plan has expired |
| 502 | invalidPlanPda | Invalid Plan PDA derivation |
| 503 | invalidSubscriptionPda | Invalid subscription PDA derivation |
| 504 | notPlanOwner | Caller is not the plan owner |
| 505 | subscriptionPlanMismatch | Subscription does not belong to this plan |
| 506 | unauthorizedDestination | Destination not in plan whitelist |
| 507 | invalidNumDestinations | No valid destinations provided |
| 508 | subscriptionCancelled | Subscription cancelled and past valid period |
| 509 | subscriptionAlreadyCancelled | Subscription already cancelled |
| 510 | subscriptionNotCancelled | Subscription is not cancelled |
| 511 | invalidEndTs | End timestamp must be zero or in the future |
| 512 | invalidPlanStatus | Invalid plan status value |
| 513 | planImmutableAfterSunset | Plan cannot be updated after sunset |
| 514 | sunsetRequiresEndTs | Sunset requires a non-zero end timestamp |
| 515 | planNotExpired | Plan must be expired to delete |
| 516 | planClosed | Plan account has been closed |
| 517 | alreadySubscribed | Already subscribed to this plan |
| 518 | planAlreadyExists | Plan account already exists |
| 519 | planTermsMismatch | Subscription plan terms do not match the current plan |
| 520 | planEndTsCannotExtend | A finite plan end timestamp can only be shortened, not removed or extended |
| 600 | invalidEventAuthority | Invalid event authority PDA |
| 601 | invalidEventData | Invalid event data |
| 602 | invalidEventTag | Invalid event tag prefix |
| 603 | invalidEventDiscriminator | Unknown event discriminator |
| 604 | invalidSelfProgram | Self program account does not match this program |