Skip to content

Init user delegate - #44

Closed
Brennan Lamey (brennanjl) wants to merge 7 commits into
brennan/pulls-migrationfrom
brennan/init-user-delegate
Closed

Brennan Lamey (brennanjl) wants to merge 7 commits into
brennan/pulls-migrationfrom
brennan/init-user-delegate

Conversation

@brennanjl

Copy link
Copy Markdown

We have this migration system to facilitate moving from one contract to another. Something I want to maintain is, for legacy card programs (called merchants in the bridge-cards contract), new users can still onboard using the legacy approval flow. In order to do this, I think we need init_if_needed.

Can you see if this is safe to add?


What this does

Adds init_if_needed to user_delegate_account in cpi_transfer so that new users can be onboarded onto legacy merchants without a separate add_or_update_user_delegate call.

New user onboarding flow:

  1. User computes the deterministic UserDelegateState PDA address offline — seeds are [USER_DELEGATE_SEED, merchant_id, mint, user_ata], computable without any on-chain interaction.
  2. User calls SPL Approve to set that PDA as their token account delegate (SPL doesn't require the account to exist yet).
  3. On the first call to transfer_using_legacy_delegate in the spender program, this instruction creates the UserDelegateState via init_if_needed in the same transaction, then executes the SPL transfer.

Safety analysis

  1. Auth chain unchanged — only the spender program (via caller_proof / SpenderState PDA) can call this instruction. The spender's debitor initiates and pays for account creation.
  2. Zero-limit accounts are safe on this path — cpi_transfer uses UserDelegateState only as an SPL signing authority; it never reads per_transfer_limit or period limits. Those are only checked by debit_user, which is blocked post-migration.
  3. Re-initialization attack doesn't apply — Anchor's init_if_needed checks the discriminator; an existing account won't be overwritten. PDA seeds also prevent account substitution.
  4. Griefing (pre-funded PDA address) — handled correctly by Anchor's init_if_needed.
  5. Pre-migration note — if called for a new user before set_migrated(true), a zero-limit UserDelegateState is created. This would block debit_user for that user until add_or_update_user_delegate sets proper limits. Acceptable since the merchant manager can fix it, and post-migration debit_user is blocked anyway.

Changes

  • user_delegate_account → init_if_needed with payer = debitor
  • Add debitor: Signer<'info> (mutable — payer for account creation)
  • Add system_program: Program<'info, System>
  • Handler sets user_delegate_account.bump to handle newly-created accounts

🤖 Generated with Claude Code

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Committed-By-Agent: claude
Anchor deserializes Account<T> before applying realloc constraints.
On mainnet, the existing BridgeCardsState account is 41 bytes (no
`migrated` field), so deploying the new binary would cause every
instruction — including set_migrated itself — to fail with a Borsh
UnexpectedEof, with no on-chain recovery path.

Fix: change the state account to UncheckedAccount so realloc can
happen before Borsh deserialization. Seeds, owner, discriminator, bump,
and admin are all validated manually in the handler after expansion.

Adds set_migrated_tests.rs covering the basic cases and an explicit
upgrade-path test that shrinks the account to the old 41-byte layout
and verifies set_migrated can repair it.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Committed-By-Agent: claude
The old approach added `migrated: bool` to BridgeCardsState, which grew
the account from 41 to 42 bytes. This required a realloc on first call
to set_migrated, and deploying the new binary would break all callers
immediately (Borsh UnexpectedEof) with no recovery path in the same tx.

New approach: a separate MigrationState PDA at seeds [b"migration"].
- When the PDA exists (owned by bridge-cards): program is migrated.
- When it doesn't: program operates normally.
- set_migrated(true)  → create_account CPI
- set_migrated(false) → lamports zeroed, owner reassigned to system program

BridgeCardsState stays at 41 bytes. The migration binary deploys and
works against all existing mainnet accounts immediately. Zero downtime
with a single upgrade — no two-phase deploy, no atomic batch needed.

Deployment order:
  1. Deploy updated Monorail (passes migration_state PDA to affected
     instructions — old binary ignores extra accounts, no failures).
  2. Deploy migration binary. New binary validates migration_state.

All 52 tests pass.

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

Both instructions loaded BridgeCardsState only for the migrated check,
which was replaced with the MigrationState PDA approach. Neither
instruction uses the state account for any other constraint or logic,
so it is redundant. Removing it reduces the interface change (callers
only need to pass migration_state, not state + migration_state).

Addresses reviewer comment on PR #40.

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

For a zero-downtime deploy, Monorail is updated first to send the extra
migration_state account, and the new binary is deployed afterward.
This only works if migration_state is at the END of each instruction's
account list — extra accounts go to remaining_accounts in the old binary
without shifting existing account positions.

Addresses reviewer ordering concern on PR #40.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Committed-By-Agent: claude
Allows new users to be onboarded onto legacy merchants without a separate
add_or_update_user_delegate call. The caller (spender debitor) pre-approves
the deterministic PDA address as their SPL delegate; the first call to this
instruction creates the UserDelegateState in the same transaction.

New accounts are zero-initialized (zero transfer limits) which is safe on
the CPI path — only debit_user checks limits, and that instruction is blocked
post-migration. The spender program's destination whitelist and pause flag
are the operative controls here.

Adds `debitor` (mutable signer, payer for account creation) and
`system_program` to the account list. Account ordering: caller_proof,
user_delegate_account, debitor, user_token_account, destination_token_account,
mint, token_program, system_program.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Committed-By-Agent: claude
@brennanjl
Brennan Lamey (brennanjl) changed the base branch from main to brennan/pulls-migration September 30, 2026 17:40
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