Skip to content

Port Spender program into Bridge Cards - #43

Closed
Brennan Lamey (brennanjl) wants to merge 13 commits into
mainfrom
brennan/port-spender-into-cards
Closed

Brennan Lamey (brennanjl) wants to merge 13 commits into
mainfrom
brennan/port-spender-into-cards

Conversation

@brennanjl

Copy link
Copy Markdown

Upgrade Bridge Cards Solana program to inherit Spender Program functionality while keeping debit_user running without downtime. See investigation 2c9b8501 for full design notes.

Adds spender's delegation model directly to bridge-cards, eliminating
the need for a separate spender program or a CPI bridge.

New state (seed "spender_state", separate from BridgeCardsState):
- SpenderState: governor, manager, debitor, pauser, paused

New PDAs:
- MerchantDelegateState [merchant_delegate, merchant_id:[u8;32]]
- DelegateDestinationState [merchant_destination, merchant_id:[u8;32], ata]

New instructions:
- initialize_spender_state, update_{governor,manager,debitor,paused,pauser}
- setup_merchant_delegate (bulk-init destinations via remaining accounts)
- add_delegate_destination, close_delegate_destination
- transfer_using_single_delegate: SPL-approve-based transfer
- transfer_using_subscription_delegate: fixed/recurring delegation CPI

Also upgrades Anchor 0.31 → 1.0 and Rust 1.85 → 1.89 (required by
subscriptions crate's transitive Solana SDK 3.x dependencies). Existing
debit_user and add_or_update_* instructions are unchanged.

The integration test crate compiles but requires further migration from
litesvm 0.6→0.12 APIs; library unit tests (7) all pass.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Committed-By-Agent: claude
- Replace litesvm_token::spl_token_2022::id() with anchor_spl::token_2022::ID
- Replace litesvm_token::spl_token_2022::state::Account with spl_token::state::Account
  (safe for non-extended accounts; same on-chain layout)
- Remove spl-token-2022 v5 dep (zeroize conflict with litesvm); use anchor_spl re-export instead

All 46 integration tests and 7 unit tests pass.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Committed-By-Agent: claude
…ntrol fix

- Add transfer_using_legacy_delegate: bridges old user-delegate PDAs with new
  global access control; validates destination via spender-style allowlist,
  init_if_needed on UserDelegateState, IS pausable, no velocity controls
- Fix update_paused: pauser/admin/governor can pause; only admin/governor can unpause
- Fix debit_user: remove velocity checks (validate_debit_and_update); keep exact
  account interface and mut constraint for prod continuity; note it is not pausable
- Add update_spender_admin: two-party admin rotation for SpenderState
- Add LegacyDelegateTransfer and SpenderAdminUpdated events
- Add Unauthorized error code

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Committed-By-Agent: claude
@socket-security

socket-security Bot commented Sep 21, 2026 •

Copy link
Copy Markdown

Groups instructions that should not be used for new work
(debit_user, initialize, add_or_update_*, update_admin, close_account)
into a legacy/ submodule. All still re-exported at the crate level so
existing behavior is unchanged; 46 tests pass.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Committed-By-Agent: claude
Documents what is net-new vs. ported vs. legacy, the two access control
systems, state accounts, transfer paths side by side, migration steps,
and Anchor version upgrade notes.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Committed-By-Agent: claude
Replaces the program-keypair signer check with a constraint that the
existing BridgeCardsState admin signs. This ties SpenderState bootstrap
to the party already in control of the program, preventing an unauthorized
actor from initializing the new role hierarchy.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Committed-By-Agent: claude
Reflects new dual-system architecture: spender role hierarchy alongside
legacy path, updated PDA table, transfer path comparison table, removed
velocity controls section, links to SPENDER_MIGRATION.md. Client
integration code unchanged (user approval flow is the same).

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Committed-By-Agent: claude
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Committed-By-Agent: claude
MEDIUM: Add legacy_merchant_id to MerchantDelegateState
- setup_merchant_delegate now accepts legacy_merchant_id: u64 (0 = no legacy binding)
- transfer_using_legacy_delegate validates merchant_delegate_state.legacy_merchant_id == merchant_id
  preventing a compromised debitor from routing a user's approval for Merchant A
  to destinations allowlisted under Merchant B

Event: add admin to SpenderStateInitialized so off-chain indexers can reconstruct
the initial admin without a separate source

Tests (54 total, all passing):
- transfer_using_legacy_delegate: success, init_if_needed, cross-merchant attack
  blocked, wrong debitor rejected, paused rejected
- update_paused: admin can pause/unpause, pauser can pause but not unpause,
  stranger cannot pause
- debit_user: update velocity-control tests to reflect removal of those checks

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Committed-By-Agent: claude
Derives the SpenderState PDA inline in the legacy handler and rejects
any attempt to close it, preventing the BridgeCardsState admin from
accidentally or maliciously bricking the spender system. Adds test to
confirm the guard fires.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Committed-By-Agent: claude
1. setup_merchant_delegate: track only newly-initialized destinations in event.
   Previously re-iterated all chunks post-loop, so skipped (already-initialized)
   destinations appeared in initial_destinations of MerchantDelegateAdded.

2. legacy/close_account: fix lamport ordering regression and add self-closure guard.
   Save lamports in local before zeroing; zero source before adding to recipient.
   Add constraint account_to_close != payer to prevent self-closure that would
   leave the account with empty data but non-zero lamports.

3. transfer_using_single_delegate + transfer_using_legacy_delegate: guard against
   receiver_ata == user_ata. A self-transfer is a no-op on balance but drains
   the SPL delegate allowance and emits a zero-amount event. New SelfTransfer error.

4. add_delegate_destination: init_if_needed -> init. Re-calling for an already-
   allowlisted destination now fails explicitly instead of silently emitting a
   duplicate DelegateDestinationAdded event that confuses indexers.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Committed-By-Agent: claude
Mirrors the check already present in transfer_using_single_delegate and
transfer_using_legacy_delegate. A debitor passing the same account for
delegator_ata and receiver_ata would drain the subscription allowance
without moving funds.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Committed-By-Agent: claude
Consistent with every other update_* instruction that validates the
incoming role address against Pubkey::default().

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Committed-By-Agent: claude
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.

1 participant