Skip to content

feat: authorization locks (supersedes #132) - #226

Merged
edo-chan merged 23 commits into
mainfrom
authlock-v3
Nov 5, 2026
Merged

edo-chan merged 23 commits into
mainfrom
authlock-v3

Conversation

@austbot

@austbot austbot commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Current merge validation (2026-11-05): base d6e99e8dd85462774464da21c63901a8873fa97f, head 9c5a3916790e77efd8c4b77ecc801820ac46d529. Includes #247's per-CPI destination enforcement while retaining paired limit charging and authorization-lock settlement. Independent source review passed on the final head; local program/state units: 194 passed, formatting and Clippy passed.

Final-head CI passed: standard 946, ProgramScope 264, SDK 411 (3 existing skips), stake 955. Lint, protocol/interface verification, release CLI and Test Results are green. Restricted CU ceilings match the approved pinned-build measurements: SOL 6371, SOL destination 6775, token 7475, token destination 8006; All retains 5080 (measured 5077). No additional margin or disabled assertions.

Validation scope: existing CI owns runtime suites. This does not claim a new real-Secp256r1 add/remove-lock test or differential old-binary compatibility with SWIG_COMPAT_OLD_SO; neither was separately exercised. Existing CloseTokenAccountV1 pair-selection behavior remains a separate pre-existing follow-up.


Supersedes #132. That branch was 283 commits behind main and conflicted with nearly every file it touched, so this re-implements authorization locks on top of main's typed tail framework.

What it does

A lock reserves amount of one wallet asset account until expiry_slot. The asset is the swig wallet address for native SOL (all-zero mint) or one token account the wallet address owns. Locks work like card authorization holds:

  1. Other roles are held off. While Bob's lock is active, every other role, including All, AllButManageAuthority and the owner, can spend only the account's balance minus the active locks it does not hold.
  2. The holder can spend its held funds. Bob's own active locks do not restrict Bob's SignV2. Only other roles' locks are protected from him.
  3. Spending captures the hold. After Bob's SignV2 runs, each locked account's net outflow (before - after, never negative) is taken off Bob's locks on that account, oldest (lowest lock_id) first, never below 0. The new amounts are written back to the config tail. Any outflow beyond Bob's locks came from free balance, and the floor in rule 1 has already protected other roles' locks. Inflows never raise a lock.
  4. Limits are charged on spend, not at lock time. Creating a lock checks that it fits the role's remaining capacity (or the balance for All/AllButManageAuthority) but uses up no limit. When Bob spends, his normal limits are charged. After charging and capturing, his remaining active locks must still fit his remaining capacity.

Deposits are never blocked. A card flow is: place a hold, then spend against it. The spend captures the hold, and releasing it voids whatever is left.

Example with mixed holders: the balance is 100, Bob holds 50 and Carol holds 30. Bob may end at ≥ 30, Carol at ≥ 50, and every other role at ≥ 80.

Protocol changes

  • Instructions: AddAuthorizationLockV1 (25), RemoveAuthorizationLockV1 (26)
  • Permission: ManageAuthorizationLocks { max_duration_slots } (27)
  • Storage: a typed tail entry, TailKind::AuthorizationLocks = 3, of 88-byte records, max 32 locks. Lock ids are never reused. Every existing tail-preserving realloc path carries the entry. SavedTail moved to the heap so the larger tail fits.
  • Enforcement: LockGuard runs on both SignV2 paths, the unrestricted All/AllButManageAuthority path and the limited one. After execution, each writable locked account must satisfy balance >= min(balance_before, reserved by locks the signer does not hold). For every signer, the holder included, token authority, delegate and close authority must stay unchanged, and a frozen account stays guarded.
  • Capture write-back: SignV2 lowers the signer's lock amounts in place in the tail: it writes only the changed amount bytes, checked by lock id. It never removes, compacts or reallocs the tail, never moves lamports, and needs no extra accounts (no payer, rent claimer, instructions sysvar or mint). Tests assert both. A lock captured to 0 stays stored with amount == 0. It is inactive (is_active requires a non-zero amount) and can be pruned like an expired lock: the creator can remove it at any time, and All/AllButManageAuthority/ManageAuthority/ManageAuthorizationLocks roles can remove it too. The reason is that SignV2 has no payer or rent-claimer account, so it has nowhere to send freed rent that respects the rent claimer. RemoveAuthorizationLockV1 reclaims the space with the usual rent routing. Leaving the excess lamports in the config would instead block CloseSwigV1 (WalletNotEmpty). Tail validation now accepts zero amounts, and creation still rejects them.

Account layout and ProgramExec support

ProgramExec authenticates against the preceding instruction's first two accounts and requires them to be the config (index 0) and the wallet address (index 1). Both lock instructions now put the wallet address second, so ProgramExec roles can add and remove locks. ProgramExecSession roles authorize locks with their session key once the program has opened a session.

# AddAuthorizationLockV1 RemoveAuthorizationLockV1
0 swig (w) swig (w)
1 swig wallet address swig wallet address
2 payer (w, s) payer (w, s)
3 system program system program
4 asset account rent claimer (w), only when one is set
5 mint (token locks)

Authority accounts follow: the Ed25519 signer, the instructions sysvar for secp256r1 and ProgramExec, nothing extra for secp256k1. RemoveAuthorizationLockV1 now also checks that the wallet address is the swig's canonical wallet PDA (InvalidSeedSwigAccount), just as add does. The interface adds new_with_program_exec and new_with_program_exec_ix_index for both instructions, and every remove builder now takes swig_wallet_address.

Creation rules

  • Every lock requires ManageAuthorizationLocks, including for All and AllButManageAuthority. No lock may outlast the role's max_duration_slots.
  • Amount: a role that also holds All or AllButManageAuthority can lock up to the account's lockable balance. Any other role can lock any token up to its own spend capacity for that asset (the same limits SignV2 would charge).
  • A role cannot set or change its own max_duration_slots through UpdateAuthorityV1 (AuthorizationLockDurationSelfUpdate, 90). It may drop the action. The root is exempt because no other role can manage it.
  • Total active locks on an account can't exceed its lockable balance. For SOL that excludes the rent-exempt reserve.
  • Token assets must be initialized, owned by the wallet address, with no delegate and no foreign close authority.
  • Token-2022 locks must pass the mint, and mints with a PermanentDelegate are rejected.

Limits are charged on spend

  • Locking charges no limit. When the holder spends, SignV2 charges its normal SOL/token/recurring/destination limits as usual.
  • Order in SignV2: execute, then charge limits, then verify floors and capture the signer's locks, then check the remaining locks against the remaining capacity, then write back. If a limited role's remaining active locks on a mint exceed what its limits still allow, the transaction fails with AuthorizationLockExceedsSpendLimit, so a role can't hold a reservation it could never spend.
  • Example: Bob has a 100 limit and a 100 lock and spends 100. His limit goes to 0 and his lock to 0 (inactive), and the check passes.
  • Example: Bob has a 1000 token limit and a 600 lock on account A. Spending 400 from account B is fine, but spending 401 from B fails because 599 left would not cover 600. Spending from A captures the lock as the limit is charged, so the two fall together.
  • CloseTokenAccountV1 also charges WSOL closes to the role's limits, and it keeps the same remaining-capacity check (it cannot close a locked account, so there is nothing to capture).
  • Capacity is what SignV2 would allow at the current slot:
    • one-shot limits use their remaining amount;
    • recurring limits use what's left in the current window, or the full allowance once the window would reset;
    • with both a general and a destination limit, the smaller applies.
  • Destination-only roles are checked against the sum of their destination limits, which doesn't guarantee that a capture can go to a particular destination.

Protecting the creator

  • Only the creating role can release an active lock. Admin roles can prune locks once they expire.
  • While the creator role has active locks, these are blocked: RemoveAuthority on it, UpdateAuthority on it, ReplaceAuthority on it by another role, CloseSwig, and closing a locked token account.
  • Once all of a role's locks are inactive (expired or captured to 0), a ManageAuthority-only role, an All role, or the role itself can remove it, rewrite it or replace its signer. RemoveAuthorityV1 also prunes the removed role's inactive lock records in the same instruction. It shrinks the tail and refunds their rent along with the role's, to the payer or to the configured rent claimer. Other roles' locks and the lock-id counter are preserved. Role ids come from a monotonic counter and lock ids are never reused, so no new role can inherit a record. One active lock still blocks all three operations. AllButManageAuthority still cannot remove roles at all.
  • One update is allowed: an admin can remove only the role's ManageAuthorizationLocks action. Without this, a delegate could stay irrevocable by renewing locks forever. Its existing locks still run to expiry and can still be captured.
  • None of these destructive or admin paths gets a holder exemption. Even the holder cannot close a locked token account, close the swig while any lock is active, or change a locked token account's authority, delegate or close authority (including a wSOL close through SignV2). Fully captured locks are inactive, so they no longer block these paths.
  • Sub-account instructions never debit the wallet address or its token accounts (withdrawals and closes only credit them), so they need no lock handling.

Differences from #132

Paired limits (merged from #228)

  • Fixes two charging bugs that are also on main:
    • With a lifetime and a per-window limit on the same asset, SignV2 charged only the lifetime one.
    • With both on the same destination, it charged only the per-window one.
  • SignV2 now charges both limits in a pair, for SOL, tokens, stake and destinations.
  • Same-type duplicate destination limits are now rejected (error 1014).
  • Lock capacity uses the smaller limit of each pair.
  • Existing roles that hold such pairs become stricter.
  • destination_limit_matrix_test.rs covers a global limit plus two destinations, each with a lifetime and a per-window limit. It focuses on several transfers inside one SignV2: atomic rejection, accumulation, and uncovered destinations. It also reports compute. With a global limit and two pairs, one SOL transfer costs 6,292 CU in SignV2, against 5,155 for the global limit alone.

Merged with main's security sync (#245)

c0b621b merges main through e17b7ca, which includes the deployed security-review backports (#245) and crate publishing (#243, #244, #246). All of main's changes are kept. The six conflicts (CloseSwig, CloseTokenAccount, RemoveAuthority, SignV2, util/rent.rs and the CU comparison test) keep both sides:

  • Shared secp counters. Main moved every instruction to counter::authenticate_role, which keeps same-key Secp256k1/Secp256r1 roles on one counter. AddAuthorizationLockV1 and RemoveAuthorizationLockV1 now use it too, and the lock checks in Close/Remove/Update/ReplaceAuthority use the slot it returns. New test: secp256k1_lock_signatures_cannot_be_replayed_through_a_same_key_role. The lock instructions already signed the acting role id, so a swapped role id was rejected before; it now fails on the shared counter first, the same way main's other instructions do.
  • Rent. Lock rent math uses main's SIMD-0194 minimum_balance helper.
  • SignV2. Main's wallet-PDA binding, personal-asset isolation and Token-2022 native accounting are kept. The lock guard still snapshots before any CPI and settles after isolation validates.
  • CloseTokenAccount. Main's Token-2022 native allowance accounting and AllButManageAuthority close permission are kept; a locked token account still can't be closed by anyone.

CU ceilings re-baselined. Main's SignV2 ceilings are exact measurements of its own SignV2, with 1-3 CU of headroom. This branch adds the lock-guard check to every SignV2 and charges both members of a one-shot/recurring limit pair, so the ceilings moved to this branch's numbers from the pinned verifiable build (CI run 41861486794), the same way main records them:

SignV2 path (verifiable build) main this PR
All 5,075 5,080
SOL limit 6,202 6,220
SOL destination limit 6,562 6,593
Token limit 7,234 7,253
Token destination limit 7,700 7,729
SOL / token transfer overhead 4,925 / 5,184 4,930 / 5,191

Restructured variants (single-pass pair lookup, forced inlining, an out-of-band guard slot, an earlier snapshot) moved these by only a few CU either way on the verifiable build, so none were kept.

Post-merge results (main at e17b7ca alongside, same machine):

Suite main this PR
standard (verifiable build) 776/776 932/932
program_scope_test 221/221 253/253
rust_sdk_test (+program_scope_test) 366 passed, 3 skipped 400 passed, 3 skipped
stake_tests (local validator, local toolchain) not run 938/941; the 3 misses are the CU ceilings, which main also misses on the local toolchain

Cost

Transaction CU (compute-budget instruction included), measured by lock_instruction_cu_table, sign_v2_lock_cu_table and admin_cu_comparison with a fixed swig id so PDA bump searches are deterministic. "Before" is 30bcfc0. "After" is 4628591, where the lock-tail edits happen in place, with a single validation, a single pass over the raw records and no Vec.

Path Before After
AddAuthorizationLock, SOL, All: 0 / 16 / 31 existing locks 6,359 / 10,943 / 14,580 6,162 / 7,259 / 8,194
AddAuthorizationLock, SOL, limited: 0 / 16 / 31 6,596 / 11,810 / 16,033 6,394 / 8,082 / 9,573
AddAuthorizationLock, SPL / Token-2022 within ~100 of SOL same savings
RemoveAuthorizationLock: 1 / 32 locks 4,849 / 13,083 4,428 / 4,862
RemoveAuthorizationLock with rent claimer: 1 / 32 6,818 / 15,053 4,760 / 5,194
RemoveAuthority, holder with 0 / 1 / 16+16 locks 2,910 / 4,021 / 8,966 2,639 / 3,097 / 4,520
UpdateAuthority, same 3,239 / 3,709 / 4,579 3,209 / 3,597 / 4,265
ReplaceAuthority, same 1,373 / 1,513 / 1,945 1,342 / 1,512 / 1,944
CloseTokenAccount, 0 / 1 / 32 locks 6,660 / 7,126 / 9,668 6,596 / 6,888 / 7,694
CloseSwig, 0 / 1 / 32 expired locks 5,925 / 6,292 / 7,160 5,728 / 6,125 / 6,993
SignV2 All: no tail / 1 guarded / 1 captured / 32 guarded / 32 captured 3,671 / 4,747 / 4,358 / 7,785 / 8,450 unchanged
SignV2 limited: same 5,385 / 6,580 / 6,314 / 12,346 / 13,909 unchanged

Against origin/main (af05048) with no locks:

Instruction main this PR
RemoveAuthority 2,631 2,590
UpdateAuthority 2,205 2,297
ReplaceAuthority 1,305 1,342
CloseTokenAccount 6,529 6,575
CloseSwig 5,633 5,655
SignV2 All / SOL limit / token limit 3,497 / 4,973 / 6,230 3,510 / 5,017 / 6,286

These numbers predate the merge with #245; see the section above for SignV2 against current main.

Verdict. The worst path is a limited signer capturing 32 locks in one SignV2, at about 13.9k CU. That is about 7% of the 200k per-instruction default and 1% of the 1.4M per-transaction maximum, against about 5.4k for a lock-free limited transfer. Every lock-management and admin path stays under 10k at the maximum lock count. The 32-lock maximum is kept: lowering it to 16 would save only about 1.6–3k CU in that worst case and nothing for ordinary wallets.

SignV2's limited path keeps hashing the whole config account, lock records included. Leaving the records out would save about 2.7k CU at 32 locks. But it would add 12–30 CU to every limited SignV2, including lock-free wallets, and it would weaken a defence-in-depth integrity check.

Tests

  • 90 integration tests in program/tests/authorization_lock_test.rs. Non-holders spend exactly the available amount, and one unit more fails with AuthorizationLockViolated. This is covered for All and limited roles with no lock permissions, on SOL, SPL Token and Token-2022, and also covers roles whose own limit binds first, partial captures lowering others' floor, expired and captured-to-zero locks, the Bob 50 / Carol 30 / others 20 boundaries, and several spends in one or sequential transactions. Removal coverage: ManageAuthority-only, All, self and AllButManageAuthority actors, after expiry and after capture to 0. It checks size, lamports and rent-claimer reconciliation, that a new role inherits nothing, Update/ReplaceAuthority on an inactive holder, and that a holder with one expired and one active lock is still blocked. Card-authorization coverage:
    • the holder spends within its lock and the lock is captured, then pruned at 0;
    • the holder spends past its lock from free balance, within limits;
    • captures take the oldest lock first;
    • the holder can't dig into another holder's lock (the mixed Bob 50 / Carol 30 / others case);
    • All roles can't spend into a holder's lock;
    • limits are charged on spend, and capture happens before the capacity check;
    • a holder can't keep locks larger than its remaining capacity;
    • inflows never raise a lock;
    • the holder exemption for SOL, SPL Token, Token-2022, a ProgramExecSession role and a ProgramExec role;
    • destination and recurring limits.
  • The existing coverage remains: enforcement on both paths, expiry, deposits, bounds, asset validation, rent neutrality, the guards, tail coexistence, secp256k1, ProgramExec add/remove, and bypass regressions (wallet-funded create_account, burn, wSOL close, SetAuthority/Approve, freeze then thaw-and-drain, Token-2022 permanent delegate, and a renewing delegate).
  • State unit tests (zero-amount locks are inactive; the in-place capture write) and 2 rust-sdk wallet tests.
  • Full CI matrix run locally on 4628591:
Suite Result
standard 884/884
program_scope_test 227/227
rust_sdk_test (+program_scope_test) 374 passed, 3 skipped
stake_tests (local validator on :18899) 893/893

cargo fmt --check is clean.

Not in this PR

  • TypeScript protocol SDK and developer SDK support.
  • Docs for swig-docs.
  • An external audit of the new instructions is recommended before mainnet.

🤖 Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.

Re-implement authorization locks (PR #132) on current main. A lock reserves
part of one wallet asset account (the swig wallet address for native SOL, or
a token account it owns) until an expiry slot. While active, no role,
including All, can move that account below the reserved amount.

- Store locks as a typed tail entry (TailKind::AuthorizationLocks) so every
  existing tail-preserving realloc path carries them. SavedTail is now
  heap-backed to fit the larger tail.
- AddAuthorizationLockV1 (24) / RemoveAuthorizationLockV1 (25) and the
  ManageAuthorizationLocks permission (27). Delegated locks are bounded by
  the role's own spend limits for the asset and a max duration.
- Enforce locks in SignV2 on both the unrestricted and limited paths, and
  check token account identity (authority, delegate, close authority) for
  locked accounts, including frozen ones.
- Only the creating role can release an active lock (capture = release and
  spend in one transaction). Block removing, rewriting, or taking over the
  creator role while it has active locks, except revoking its
  ManageAuthorizationLocks permission. Block CloseSwig and closing locked
  token accounts.
- Reject Token-2022 mints with a permanent delegate.
- Interface builders, rust-sdk support, and tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A role's active authorization locks now count against its own spending
limits for as long as they are active, not only at creation. After SignV2
(and CloseTokenAccountV1) charge a limited role, its remaining limits for
each mint must still cover its active locks on that mint, so it cannot spend
the allowance that backs its reservations.

Capacity now reflects what SignV2 would allow at the current slot: one-shot
limits use their remaining amount and recurring limits use what is left in
the current window, or the full allowance once the window would reset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread state/src/tail/mod.rs Resolved
Destination limits let a role authorize spends to specific accounts, and
authorization locks reserve spend capacity against the role's limits.
Three related bugs:

1. A role could be granted the same destination-limit permission twice
   for the same destination. SignV2 only ever charges the first match, so
   the duplicate is inert, but it stays listed in the role and inflates
   how much spend capacity an authorization lock can reserve. Creating a
   duplicate is now rejected with DuplicateDestinationLimit (1014).

2. When a role holds both a one-shot limit and a recurring limit for the
   same destination (or for the same general asset), SignV2 charged only
   the recurring ledger, so the one-shot total never decreased. Both
   ledgers now charge in the same transaction: the debit must fit the
   recurring window and the one-shot budget, and both count down.

3. Lock reservation capacity summed a pair's one-shot and recurring
   amounts, and ignored the recurring half of a general pair. Capacity is
   now the honest smaller bound: for each destination, the pair's
   min(one-shot, recurring), capped by the same rule for general limits.

Nothing changes for roles that hold only one limit of each kind, and
already-created wallets are unaffected by the dedup check (it runs on
authority creation and updates only).
…nations

SignV2 end-to-end tests for a role stacking a general SOL limit with
destination pairs on several destinations: the recurring window binds
first, the destination one-shot drains across windows, the general pool
tracks every successful send, and unchanged ledgers prove rejections are
atomic. Lock reservation capacity honors the same minimum rule when
limit pairs stack with a general limit.
austbot and others added 3 commits October 21, 2026 14:53
fix: dedupe destination limits and charge paired spend limits together
Covers a role with a global SOL limit and two destinations that each hold a
lifetime and a window destination limit: independent per-destination
ledgers, the global limit binding across destinations, atomic rejection,
accumulation within one window, uncovered destinations, and SignV2 compute
for the matrix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
austbot and others added 3 commits October 22, 2026 11:20
Carry the destination key and recurrence inside LimitKind::Destination
instead of alongside it, so the missing-key case cannot be expressed and
the on-chain panic path disappears. Also drops the second permission()
lookup per destination action. Behavior is unchanged: per destination key,
the first one-shot and first recurring limit count.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Duplicate keyed spend limits are now rejected only when they are added:
ReplaceAll checks the full new action list and AddActions checks the
appended actions against the role's existing ones. Remove operations no
longer re-validate the remaining actions, so roles created before this rule
keep any duplicates they hold and stay updatable.

The same rule now covers general token limits: a role may hold at most one
TokenLimit and one TokenRecurringLimit per mint (DuplicateTokenLimit, 1015).
General SOL and stake limits were already non-repeatable.

Adds backward_compat_test, a differential test that builds wallets with a
previous program build and compares every outcome and final account state
against an upgraded program. It runs when SWIG_COMPAT_OLD_SO points at the
previous build. Against main, the only changes are rejected new duplicates
and paired limits now charging both twins.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
austbot and others added 2 commits October 28, 2026 14:12
…ration

- Every AddAuthorizationLockV1 now needs ManageAuthorizationLocks, and its
  max_duration_slots bounds every lock, including for All and
  AllButManageAuthority. Those roles still lock up to the wallet balance;
  other roles stay bounded by their own spend limits.
- A role may not set or change its own max_duration_slots via
  UpdateAuthorityV1 (new AuthorizationLockDurationSelfUpdate error). Dropping
  the action is allowed. The root is exempt since no other role can manage it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Conflict resolutions:
- Instructions: main's CreateReservedV1 keeps 24; AddAuthorizationLockV1
  and RemoveAuthorizationLockV1 move to 25 and 26. IDL regenerated.
- SwigStateError: main's InvalidSeedPrefix keeps 1012; the lock and
  duplicate-limit errors shift to 1013-1016.
- Tail: AuthorizationLocks keeps kind 3 (reserved by #239) alongside
  ConfigSeedPrefix at 4. MAX_TAIL_LEN covers all four kinds; SavedTail stays
  heap-backed since a full lock entry is ~2.8 KB.
- close_token_account_v1 uses main's config_signer plus the lock guard.
- The max-size tail test now includes a full 32-lock entry across all 24
  orderings.
- SignV2 token-path CU baselines +2 (6289, 7220) for the extra tail kind.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ProgramExec authenticates against the preceding instruction's first two
accounts and expects them to be the config and the wallet address at
indexes 0 and 1. Both lock instructions had the payer at index 1, so a
ProgramExec role could never authorize a lock.

Reorder the (unreleased) lock layouts so the wallet address comes second:

- AddAuthorizationLockV1: swig, swig_wallet_address, payer,
  system_program, asset_account, [mint], authority accounts
- RemoveAuthorizationLockV1: swig, swig_wallet_address, payer,
  system_program, [rent claimer], authority accounts

RemoveAuthorizationLockV1 now verifies that the wallet address is the
swig's canonical wallet PDA (InvalidSeedSwigAccount), as add already did,
so the ProgramExec binding is meaningful.

Interface: reorder the builders, add swig_wallet_address to every remove
builder, and add new_with_program_exec / new_with_program_exec_ix_index
for both instructions. rust-sdk passes the wallet address through to
remove. The IDL is updated by hand to match.

Tests cover ProgramExec and ProgramExecSession roles adding and removing
locks, a wrong wallet address on remove, and ProgramExec proofs whose
accounts don't match.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@edo-chan edo-chan left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Heap allocation concern in the lock-capacity check:

Comment thread program/src/authorization_lock.rs Resolved

@edo-chan edo-chan left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion to aggregate active authorization locks once per SignV2 and reuse the totals.

Comment thread program/src/authorization_lock.rs Resolved

@edo-chan edo-chan left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Concern about long-lived locks preventing revocation of a compromised developer role.

Comment on lines +226 to +231
if authorization_locks::role_has_active_locks(
parts.tail,
remove_authority_v1.args.authority_to_remove_id,
Clock::get()?.slot,
)? {
return Err(SwigError::AuthorizationLockActive.into());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can a long-lived lock make a rogue developer role effectively non-revocable for the duration of the lock?

For example:

  • Role 0 is the user/root.
  • Role 1 is a developer already granted spending permissions and ManageAuthorizationLocks with a long max_duration_slots.
  • The developer goes rogue and creates a tiny lock, even one base unit, with expiry at its granted maximum.

Role 0 then cannot remove role 1 because this guard returns AuthorizationLockActive. The corresponding guards in UpdateAuthorityV1 and ReplaceAuthorityV1 also prevent reducing its spending permissions or replacing its signer. Removing only ManageAuthorizationLocks is allowed, but that only stops new locks: the existing lock and the developer's broader spending permissions remain until the lock is released or expires.

This does not freeze the entire wallet; the concern is that a tiny reservation prevents the user from revoking a compromised developer's much larger spending authority, which remains usable within its limits while the role can authenticate.

Is that the intended trust contract? Can we preserve the existing payment reservation while allowing the user to revoke the developer's unrelated spending authority, rather than protecting the entire role for the lock's lifetime?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if removing a role also atomically removes all authorization locks created by that role?

That would let the user fully revoke a rogue developer without waiting for its locks to expire, while leaving other roles’ locks untouched. The tradeoff is that existing payment holds would become cancellable through role removal, so we should make that behavior—and who is authorized to trigger it—explicit.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

manage authority can do this but i think auth locks should hold they need to be a strong guarantee
not something the user who likley has manage authority can remove this can never be used for credit with soft guarantees

austbot and others added 2 commits November 2, 2026 12:20
A lock now behaves like a card authorization hold instead of binding every
role, the creator included:

- Other roles, All/AllButManageAuthority included, may spend only the
  account balance minus the active locks they do not hold. The SignV2 guard
  floor for an account is min(before, sum of active locks whose role_id is
  not the signer's).
- The holder may spend its own reserved funds. After execution SignV2
  captures the signer's locks on each guarded account by the account's net
  outflow (before - after, never negative), oldest lock first, never below
  zero, and writes the new amounts back to the config tail in place.
- Limits are charged on spend, not at lock time. The remaining-capacity
  check now runs after charging and capturing, so a limited role cannot keep
  locks its limits can no longer cover.
- A lock captured to zero stays stored with amount 0: it is inactive
  (is_active requires a non-zero amount) and prunable like an expired lock.
  SignV2 has no payer or rent-claimer account to refund freed rent to, so it
  never resizes the config; RemoveAuthorizationLockV1 still reclaims the space
  and routes rent as before.

Identity checks on locked token accounts, lock creation, release, pruning,
and instruction layouts are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SignV2 lock handling, same semantics:
- One pass over the raw lock records via a zero-copy LockRecord view, with
  fixed-size arrays (at most 32 locks) instead of per-lock Vecs; the guard
  is heap-allocated once, and only when a lock matters to the transaction.
- No guard and no further work when the tail has no lock entry, or no active
  lock is on a writable account and the signer holds none.
- Captures write only the lowered amount bytes, verified by lock id, through
  the config account; the config is never resized and no lamports move.
- Wallet balances are compared in raw lamports; the rent sysvar is read at
  most once, and only when another role's lock must bound an actual outflow
  (it was read twice per locked SOL account).
- The capacity re-check reads the captured amounts back from the records in
  one pass without allocating.

Tests: exact available-amount boundaries (+1 fails with
AuthorizationLockViolated) for All and limited non-holders on SOL, SPL Token
and Token-2022; binding-limit roles; partial captures; expired and
captured-to-zero locks; Bob 50 / Carol 30 / others; several spends in one
or sequential transactions; capture to zero leaves the config length,
lamports and record in place; SignV2 accounts are identical with or without
locks; a per-scenario compute table with budgets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@edo-chan edo-chan left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed acce97a. CI passes, and the local SBF/LiteSVM CU sweep from 0 through 32 locks passed for both spending unreserved funds and capturing the signer's own locks, using a single six-decimal token account with three repetitions per case.

The existing role-revocation design discussion remains open: #226 (comment)

austbot and others added 3 commits November 2, 2026 15:24
ManageAuthority (or All, or the role itself) can already remove a role once
none of its authorization locks is active: expired or captured to zero.
RemoveAuthorityV1 now also drops that role's inactive lock records in the
same instruction, shrinking the tail and refunding their rent with the
role's to the payer or the configured rent claimer. Other roles' locks and
the lock-id counter are preserved. Removal stays blocked while the role has
any active lock.

Role ids come from a monotonic counter and lock ids are never reused, so
nothing could have inherited the records; pruning keeps the tail free of
orphaned records and returns their rent.

Tests cover ManageAuthority-only, All, self, and AllButManageAuthority
actors; expiry and capture to zero; size, lamports, and rent-claimer
reconciliation; a new role inheriting nothing; Update/ReplaceAuthority on an
inactive holder; and a holder with one expired and one active lock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AddAuthorizationLockV1, RemoveAuthorizationLockV1, and RemoveAuthorityV1's
pruning rebuilt the whole tail: they validated it two to four times,
decoded every record into a Vec, re-serialised all of them, and copied the
result back, about 265 CU per stored lock. They now validate once, read the
raw records in a single pass, and edit in place: add appends one record and
moves only the bytes after the lock entry, remove closes the gap, and role
removal compacts the role's records in its saved tail copy.

RemoveAuthorizationLockV1 also passes its already-verified wallet address to
the rent-claimer refund check instead of re-deriving it with
find_program_address (about 2k CU). CloseSwigV1 and RemoveAuthorityV1 read
the Clock once, CloseTokenAccountV1 splits the config once and scans raw
records, UpdateAuthorityV1 reuses its tail validation, and the empty-tail
lock checks return early. Semantics are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
lock_instruction_cu_table measures AddAuthorizationLockV1 (SOL, SPL,
Token-2022; All and limited; 0 to 31 existing locks),
RemoveAuthorizationLockV1 (creator release and admin prune, with and
without a rent claimer, 1 and 32 locks), Remove/Update/ReplaceAuthority on
an inactive-lock holder, and CloseTokenAccount/CloseSwig with locks, and
budgets the per-lock cost so a whole-tail rebuild cannot creep back.
admin_cu_comparison measures the same admin instructions without locks
using only builders main also has, so it can be run there unchanged.

Test wallets use a fixed swig id so PDA bump searches, and the measured
costs, are deterministic.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
austbot and others added 4 commits November 4, 2026 09:44
Brings in the security-review sync (#245) and crate publishing (#243,
#244, #246). Conflict resolutions keep both sides:

- Lock instructions now authenticate through counter::authenticate_role,
  so they get the shared secp counter (duplicate-key replay) fix.
- Close/replace/update/remove authority keep main's authenticate_role and
  use its returned slot for the lock checks.
- Lock rent math uses the SIMD-0194 minimum_balance helper.
- SignV2 keeps main's wallet-PDA binding and isolation; the lock guard
  still snapshots before CPIs and settles after isolation validates.
- CU comparison baselines take main's values.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main's ceilings are exact measurements of its own SignV2 (1-3 CU of
headroom). This branch adds the lock-guard check on every SignV2 and
charges both members of a one-shot/recurring limit pair. Measured with
the pinned verifiable build (CI run 41861486794): All +5, limited roles
+18 to +31 CU.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@edo-chan
edo-chan marked this pull request as draft November 5, 2026 14:37
@edo-chan
edo-chan marked this pull request as ready for review November 5, 2026 14:56

@edo-chan edo-chan left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved the final merged head after independent source review and green CI. Per-CPI destination enforcement and paired limit charging are both retained; lock settlement follows accounting. Standard 946, ProgramScope 264, SDK 411 (3 existing skips), and stake 955 passed. The four restricted CU ceilings equal the approved pinned-build measurements. Existing documented lock guarantees and nonblocking follow-ups remain unchanged.

@edo-chan
edo-chan merged commit f9b14b7 into main Nov 5, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants