Skip to content

Commit 8b23a0d

Browse files
committed
feat(solana): implement mpc-spending-limit Anchor program
Add on-chain spending limits enforcement for Solana MPC wallets using Anchor framework 0.32.1. Features: - PDA-based SpendingConfig with per-tx, daily, weekly, monthly limits - Slot-based period tracking with automatic resets - WhitelistEntry PDAs for allowed/blocked addresses - Guardian support for emergency pause functionality - Cooldown periods for limit updates - Atomic validate_and_record instruction Instructions: - initialize/initialize_for: Create spending config - update_limits: Modify limits with optional cooldown - toggle_pause: Pause/unpause by authority or guardian - set_guardian: Set emergency guardian - add_to_whitelist/remove_from_whitelist: Manage whitelist - validate_transfer: Check if transfer is allowed - record_spending: Update counters after transfer - validate_and_record: Atomic validation + recording Dependencies: - anchor-lang: 0.32.1 - anchor-spl: 0.32.1 - solana-sdk: 2.3 (workspace)
1 parent 4c73175 commit 8b23a0d

14 files changed

Lines changed: 7716 additions & 1101 deletions

File tree

Cargo.lock

Lines changed: 5485 additions & 1097 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ members = [
55
"crates/mpc-wallet-wasm",
66
"crates/mpc-wallet-relay",
77
"crates/mpc-wallet-cli",
8+
"programs/mpc-spending-limit",
89
]
910

1011
[workspace.package]
@@ -104,10 +105,10 @@ alloy-consensus = "0.6"
104105
alloy-eips = "0.6"
105106
alloy-rpc-types = "0.6"
106107

107-
# Solana (use v2.2 for zeroize compatibility with curve25519-dalek 4.x)
108-
solana-sdk = "2.2"
109-
spl-token = { version = "7.0", features = ["no-entrypoint"] }
110-
spl-associated-token-account = { version = "7.0", features = ["no-entrypoint"] }
108+
# Solana (use v2.3 for Anchor 0.32.x compatibility)
109+
solana-sdk = "2.3"
110+
spl-token = { version = "8.0", features = ["no-entrypoint"] }
111+
spl-associated-token-account = { version = "8.0", features = ["no-entrypoint"] }
111112

112113
[profile.release]
113114
lto = "thin"
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
[features]
2+
seeds = true
3+
skip-lint = false
4+
5+
[programs.localnet]
6+
mpc_spending_limit = "MpcSLim1t1111111111111111111111111111111111"
7+
8+
[programs.devnet]
9+
mpc_spending_limit = "MpcSLim1t1111111111111111111111111111111111"
10+
11+
[programs.mainnet]
12+
mpc_spending_limit = "MpcSLim1t1111111111111111111111111111111111"
13+
14+
[registry]
15+
url = "https://api.apr.dev"
16+
17+
[provider]
18+
cluster = "Localnet"
19+
wallet = "~/.config/solana/id.json"
20+
21+
[scripts]
22+
test = "yarn run ts-mocha -p ./tsconfig.json -t 1000000 tests/**/*.ts"
23+
24+
[test]
25+
startup_wait = 5000
26+
shutdown_wait = 2000
27+
28+
[test.validator]
29+
url = "https://api.mainnet-beta.solana.com"
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
[package]
2+
name = "mpc-spending-limit"
3+
version = "0.1.0"
4+
description = "Solana program for MPC wallet spending limits with PDA-based tracking"
5+
edition = "2021"
6+
license = "MIT"
7+
repository = "https://github.com/Kazopl/mpc-agent-wallet"
8+
9+
[lib]
10+
crate-type = ["cdylib", "lib"]
11+
name = "mpc_spending_limit"
12+
13+
[features]
14+
default = []
15+
cpi = ["no-entrypoint"]
16+
no-entrypoint = []
17+
no-idl = []
18+
no-log-ix-name = []
19+
idl-build = ["anchor-lang/idl-build", "anchor-spl/idl-build"]
20+
21+
[dependencies]
22+
anchor-lang = "0.32.1"
23+
anchor-spl = "0.32.1"
24+
25+
[dev-dependencies]
26+
solana-program-test = "2.3"
27+
solana-sdk = "2.3"
28+
tokio = { version = "1.0", features = ["full"] }
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
[target.bpfel-unknown-unknown.dependencies.std]
2+
features = []
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
//! Custom error types for the MPC Spending Limit program
2+
3+
use anchor_lang::prelude::*;
4+
5+
/// Errors that can occur in the MPC Spending Limit program
6+
#[error_code]
7+
pub enum SpendingLimitError {
8+
/// Transfer amount exceeds per-transaction limit
9+
#[msg("Transfer amount exceeds per-transaction limit")]
10+
PerTxLimitExceeded,
11+
12+
/// Transfer would exceed daily spending limit
13+
#[msg("Transfer would exceed daily spending limit")]
14+
DailyLimitExceeded,
15+
16+
/// Transfer would exceed weekly spending limit
17+
#[msg("Transfer would exceed weekly spending limit")]
18+
WeeklyLimitExceeded,
19+
20+
/// Transfer would exceed monthly spending limit
21+
#[msg("Transfer would exceed monthly spending limit")]
22+
MonthlyLimitExceeded,
23+
24+
/// Target address is not in the whitelist
25+
#[msg("Target address is not in the whitelist")]
26+
NotWhitelisted,
27+
28+
/// Whitelist mode requires target to be whitelisted
29+
#[msg("Whitelist mode is enabled and target is not whitelisted")]
30+
WhitelistRequired,
31+
32+
/// Target address is blacklisted
33+
#[msg("Target address is blacklisted")]
34+
TargetBlacklisted,
35+
36+
/// Unauthorized - caller is not the authority
37+
#[msg("Unauthorized - caller is not the authority")]
38+
Unauthorized,
39+
40+
/// Invalid limit configuration
41+
#[msg("Invalid limit configuration - limits must be non-zero and consistent")]
42+
InvalidLimitConfig,
43+
44+
/// Config is paused
45+
#[msg("Spending config is currently paused")]
46+
ConfigPaused,
47+
48+
/// Math overflow occurred
49+
#[msg("Math overflow occurred during calculation")]
50+
MathOverflow,
51+
52+
/// Invalid slot window
53+
#[msg("Invalid slot window for period reset")]
54+
InvalidSlotWindow,
55+
56+
/// Whitelist is full
57+
#[msg("Whitelist has reached maximum capacity")]
58+
WhitelistFull,
59+
60+
/// Entry already exists
61+
#[msg("Whitelist entry already exists")]
62+
EntryAlreadyExists,
63+
64+
/// Entry not found
65+
#[msg("Whitelist entry not found")]
66+
EntryNotFound,
67+
68+
/// Invalid authority transfer
69+
#[msg("Invalid authority transfer - new authority cannot be zero")]
70+
InvalidAuthorityTransfer,
71+
72+
/// Guardian already set
73+
#[msg("Guardian is already set")]
74+
GuardianAlreadySet,
75+
76+
/// Guardian not set
77+
#[msg("Guardian is not set for this config")]
78+
GuardianNotSet,
79+
80+
/// Cooldown period has not elapsed
81+
#[msg("Cooldown period has not elapsed for limit update")]
82+
CooldownNotElapsed,
83+
}
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
//! Initialize instruction for creating a new spending config
2+
3+
use anchor_lang::prelude::*;
4+
5+
use crate::error::SpendingLimitError;
6+
use crate::state::*;
7+
8+
/// Accounts for the initialize instruction
9+
#[derive(Accounts)]
10+
pub struct Initialize<'info> {
11+
/// The authority that will control this spending config
12+
#[account(mut)]
13+
pub authority: Signer<'info>,
14+
15+
/// The spending config PDA to initialize
16+
#[account(
17+
init,
18+
payer = authority,
19+
space = SpendingConfig::SIZE,
20+
seeds = [SPENDING_CONFIG_SEED, authority.key().as_ref()],
21+
bump
22+
)]
23+
pub spending_config: Account<'info, SpendingConfig>,
24+
25+
/// System program for account creation
26+
pub system_program: Program<'info, System>,
27+
}
28+
29+
/// Initialize a new spending configuration
30+
///
31+
/// Creates a PDA that stores spending limits for an MPC wallet.
32+
/// The authority (usually the MPC wallet address) controls all config updates.
33+
///
34+
/// # Arguments
35+
/// * `ctx` - The instruction context
36+
/// * `config_input` - Initial configuration parameters
37+
///
38+
/// # Errors
39+
/// * `InvalidLimitConfig` - If the limit configuration is invalid
40+
pub fn handler(ctx: Context<Initialize>, config_input: SpendingConfigInput) -> Result<()> {
41+
// Validate input
42+
require!(config_input.validate(), SpendingLimitError::InvalidLimitConfig);
43+
44+
let config_key = ctx.accounts.spending_config.key();
45+
let spending_config = &mut ctx.accounts.spending_config;
46+
let clock = Clock::get()?;
47+
let current_slot = clock.slot;
48+
49+
// Initialize the spending config
50+
spending_config.authority = ctx.accounts.authority.key();
51+
spending_config.guardian = None;
52+
spending_config.per_tx_limit = config_input.per_tx_limit;
53+
spending_config.daily_limit = config_input.daily_limit;
54+
spending_config.weekly_limit = config_input.weekly_limit;
55+
spending_config.monthly_limit = config_input.monthly_limit;
56+
spending_config.daily_spent = 0;
57+
spending_config.weekly_spent = 0;
58+
spending_config.monthly_spent = 0;
59+
spending_config.daily_reset_slot = current_slot + SLOTS_PER_DAY;
60+
spending_config.weekly_reset_slot = current_slot + SLOTS_PER_WEEK;
61+
spending_config.monthly_reset_slot = current_slot + SLOTS_PER_MONTH;
62+
spending_config.whitelist_only = config_input.whitelist_only;
63+
spending_config.is_paused = false;
64+
spending_config.whitelist_count = 0;
65+
spending_config.update_cooldown_slots = config_input.update_cooldown_slots;
66+
spending_config.pending_update_slot = 0;
67+
spending_config.bump = ctx.bumps.spending_config;
68+
spending_config._reserved = [0u8; 64];
69+
70+
// Emit event
71+
emit!(ConfigInitialized {
72+
config: config_key,
73+
authority: spending_config.authority,
74+
per_tx_limit: spending_config.per_tx_limit,
75+
daily_limit: spending_config.daily_limit,
76+
weekly_limit: spending_config.weekly_limit,
77+
monthly_limit: spending_config.monthly_limit,
78+
});
79+
80+
msg!(
81+
"Initialized spending config for authority {} with limits: per_tx={}, daily={}, weekly={}, monthly={}",
82+
spending_config.authority,
83+
spending_config.per_tx_limit,
84+
spending_config.daily_limit,
85+
spending_config.weekly_limit,
86+
spending_config.monthly_limit
87+
);
88+
89+
Ok(())
90+
}
91+
92+
/// Accounts for initializing with a custom authority
93+
#[derive(Accounts)]
94+
#[instruction(authority: Pubkey)]
95+
pub struct InitializeFor<'info> {
96+
/// The payer for account creation
97+
#[account(mut)]
98+
pub payer: Signer<'info>,
99+
100+
/// The spending config PDA to initialize
101+
#[account(
102+
init,
103+
payer = payer,
104+
space = SpendingConfig::SIZE,
105+
seeds = [SPENDING_CONFIG_SEED, authority.as_ref()],
106+
bump
107+
)]
108+
pub spending_config: Account<'info, SpendingConfig>,
109+
110+
/// System program for account creation
111+
pub system_program: Program<'info, System>,
112+
}
113+
114+
/// Initialize a spending config for a specific authority
115+
///
116+
/// Allows creating a config for an authority that may not be present as a signer.
117+
/// This is useful when the MPC wallet address is known but not yet operational.
118+
///
119+
/// # Arguments
120+
/// * `ctx` - The instruction context
121+
/// * `authority` - The authority pubkey to set
122+
/// * `config_input` - Initial configuration parameters
123+
pub fn handler_for(
124+
ctx: Context<InitializeFor>,
125+
authority: Pubkey,
126+
config_input: SpendingConfigInput,
127+
) -> Result<()> {
128+
require!(config_input.validate(), SpendingLimitError::InvalidLimitConfig);
129+
130+
let config_key = ctx.accounts.spending_config.key();
131+
let spending_config = &mut ctx.accounts.spending_config;
132+
let clock = Clock::get()?;
133+
let current_slot = clock.slot;
134+
135+
spending_config.authority = authority;
136+
spending_config.guardian = None;
137+
spending_config.per_tx_limit = config_input.per_tx_limit;
138+
spending_config.daily_limit = config_input.daily_limit;
139+
spending_config.weekly_limit = config_input.weekly_limit;
140+
spending_config.monthly_limit = config_input.monthly_limit;
141+
spending_config.daily_spent = 0;
142+
spending_config.weekly_spent = 0;
143+
spending_config.monthly_spent = 0;
144+
spending_config.daily_reset_slot = current_slot + SLOTS_PER_DAY;
145+
spending_config.weekly_reset_slot = current_slot + SLOTS_PER_WEEK;
146+
spending_config.monthly_reset_slot = current_slot + SLOTS_PER_MONTH;
147+
spending_config.whitelist_only = config_input.whitelist_only;
148+
spending_config.is_paused = false;
149+
spending_config.whitelist_count = 0;
150+
spending_config.update_cooldown_slots = config_input.update_cooldown_slots;
151+
spending_config.pending_update_slot = 0;
152+
spending_config.bump = ctx.bumps.spending_config;
153+
spending_config._reserved = [0u8; 64];
154+
155+
emit!(ConfigInitialized {
156+
config: config_key,
157+
authority: spending_config.authority,
158+
per_tx_limit: spending_config.per_tx_limit,
159+
daily_limit: spending_config.daily_limit,
160+
weekly_limit: spending_config.weekly_limit,
161+
monthly_limit: spending_config.monthly_limit,
162+
});
163+
164+
msg!(
165+
"Initialized spending config for authority {} (paid by {})",
166+
authority,
167+
ctx.accounts.payer.key()
168+
);
169+
170+
Ok(())
171+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
//! Instructions for the MPC Spending Limit program
2+
3+
pub mod initialize;
4+
pub mod record_spending;
5+
pub mod update_limits;
6+
pub mod validate_transfer;
7+
pub mod whitelist;
8+
9+
pub use initialize::*;
10+
pub use record_spending::*;
11+
pub use update_limits::*;
12+
pub use validate_transfer::*;
13+
pub use whitelist::*;

0 commit comments

Comments
 (0)