From 6428608cc9d78d4400ddfeb95c907be13c1ae817 Mon Sep 17 00:00:00 2001 From: Gabriele Picco Date: Wed, 1 Jul 2026 16:17:27 +0200 Subject: [PATCH 01/16] Implement rent-pending ATA materialization --- .../crates/magicblock-committor-service.md | 9 + .agents/context/crates/magicblock-core.md | 3 +- .../crates/magicblock-magic-program-api.md | 15 +- .agents/specs/validator-specification.md | 27 + Cargo.lock | 4 + docs/mimd-rent-pending-ephemeral-ata.md | 478 ++++++++++++++++++ .../src/persist/commit_persister.rs | 4 + magicblock-committor-service/src/tasks/mod.rs | 107 +++- .../src/tasks/task_builder.rs | 254 +++++++++- magicblock-core/Cargo.toml | 4 +- magicblock-core/src/tls.rs | 15 + magicblock-core/src/token_programs.rs | 173 ++++++- .../src/instruction.rs | 18 +- magicblock-processor/Cargo.toml | 1 + .../src/executor/processing.rs | 36 ++ .../tests/ephemeral_accounts.rs | 78 ++- programs/magicblock/Cargo.toml | 3 + .../magicblock/src/ephemeral_accounts/mod.rs | 2 + .../process_create_rent_pending_ata.rs | 231 +++++++++ .../src/magic_scheduled_base_intent.rs | 71 ++- .../magicblock/src/magicblock_processor.rs | 20 +- .../process_schedule_commit.rs | 69 ++- .../process_schedule_commit_tests.rs | 102 +++- .../process_schedule_intent_bundle.rs | 52 +- test-integration/Cargo.lock | 3 + 25 files changed, 1736 insertions(+), 43 deletions(-) create mode 100644 docs/mimd-rent-pending-ephemeral-ata.md create mode 100644 programs/magicblock/src/ephemeral_accounts/process_create_rent_pending_ata.rs diff --git a/.agents/context/crates/magicblock-committor-service.md b/.agents/context/crates/magicblock-committor-service.md index 9e5ce5c20..012e8b6e3 100644 --- a/.agents/context/crates/magicblock-committor-service.md +++ b/.agents/context/crates/magicblock-committor-service.md @@ -205,6 +205,15 @@ IntentExecutorImpl::execute For committed accounts with `data.len() > COMMIT_STATE_SIZE_THRESHOLD` (`256`), the task builder fetches the base account and may use diff-in-args delivery. If the base-account fetch fails, it falls back to full state args and logs a warning. This can increase transaction size and trigger buffer/ALT strategy later. +Rent-pending ATA materialization metadata is handled before normal DLP commit +tasks. For each metadata entry, `TaskBuilderImpl::commit_tasks` prepends an +e-token initialize-eATA task and a delegate-eATA-to-this-validator task, skips +base nonce/base-account fetches for the newly materialized eATA, and uses +synthetic commit nonce `1` for the following DLP commit. If an eATA already +exists and is delegated to another validator, the e-token delegate task is the +expected validator-mismatch failure gate before any DLP commit/undelegation +task is allowed to succeed. + ### Delivery preparation and cleanup flow `TransactionPreparatorImpl::prepare_for_strategy` first compiles against dummy lookup tables to fail early if the message cannot fit. It then calls `DeliveryPreparator::prepare_for_delivery`: diff --git a/.agents/context/crates/magicblock-core.md b/.agents/context/crates/magicblock-core.md index 06f23deae..a2c93ce4a 100644 --- a/.agents/context/crates/magicblock-core.md +++ b/.agents/context/crates/magicblock-core.md @@ -137,7 +137,7 @@ Use `CoordinationMode::current()`, `needs_validator_signer()`, `should_schedule_ ### Execution TLS -`tls::ExecutionTlsStash` is a thread-local queue currently used for `TaskRequest`s emitted by Magic Program task scheduling/cancel instructions. The processor clears the stash around execution and drains it after a successful transaction path. Do not use it as a cross-thread channel or persistent store. +`tls::ExecutionTlsStash` is a thread-local queue currently used for `TaskRequest`s emitted by Magic Program task scheduling/cancel instructions and for newly created rent-pending ATA pubkeys that need post-execution validation. The processor clears the stash around execution, drains scheduled tasks after a successful transaction path, and verifies newly created rent-pending ATAs before local state is committed. Do not use it as a cross-thread channel or persistent store. ### Token/eATA helpers @@ -147,6 +147,7 @@ Use `CoordinationMode::current()`, `needs_validator_signer()`, `should_schedule_ - eATA derivation: `derive_eata`, `try_derive_eata_address_and_bump`. - ATA detection/remapping: `is_ata`, `try_remap_ata_to_eata`. - eATA projection: `EphemeralAta` projection helpers require a real base ATA layout and do not synthesize legacy SPL Token accounts from raw eATA data. +- Rent-pending ATA helpers: `RENT_PENDING_ATA_CLOSE_AUTHORITY` is the rent sysvar sentinel, and `try_get_rent_pending_ata_info` recognizes only canonical delegated non-native SPL Token/Token-2022 ATAs that are not ephemeral, confined, or undelegating. Rent-pending detection builds materialization metadata for Magic Program scheduling; it is not a close authority or settlement bypass. - Native-token local projection: `normalize_native_token_account_for_local_clone` strips `is_native` and claimable lamports only for canonical delegated ATA clones, and supports both legacy SPL Token native mint and Token-2022 native mint while preserving Token-2022 account layout/extensions. - Projected ATA local safety: `normalize_projected_token_account_for_local_clone` makes virtual projected ATAs uncloseable with `close_authority = Some(Pubkey::default())`; closure/materialization belongs in settlement, not local projection. diff --git a/.agents/context/crates/magicblock-magic-program-api.md b/.agents/context/crates/magicblock-magic-program-api.md index cc4c7bd82..b017dc5d7 100644 --- a/.agents/context/crates/magicblock-magic-program-api.md +++ b/.agents/context/crates/magicblock-magic-program-api.md @@ -88,7 +88,7 @@ Use `crate::compat::{Instruction, AccountMeta, AccountInfo, Signature}` and `cra `instruction.rs` defines: -- `MagicBlockInstruction`: the primary bincode-serialized instruction enum for the Magic Program. Variants cover account modification, legacy and bundled commit scheduling, task scheduling/canceling, executable-check toggles, no-op uniqueness, ephemeral account create/resize/close, clone/chunk/cleanup, program finalization, callback attachment, account eviction, and crank execution. +- `MagicBlockInstruction`: the primary bincode-serialized instruction enum for the Magic Program. Variants cover account modification, legacy and bundled commit scheduling, task scheduling/canceling, executable-check toggles, no-op uniqueness, ephemeral account create/resize/close, local rent-pending ATA creation, clone/chunk/cleanup, program finalization, callback attachment, account eviction, and crank execution. - `CallbackInstruction`: instruction enum for the callback executor built-in. Current variant is `ExecuteCallback { instruction }`. - `PostDelegationActionExecutorInstruction`: instruction enum for the post-delegation action executor built-in. Current variant is `Execute { cloned_account_pubkey, actions }`. - `AccountCloneFields`: clone metadata (`lamports`, `owner`, `executable`, `delegated`, `confined`, `remote_slot`) that must preserve remote/local account semantics across clone instructions. @@ -183,7 +183,14 @@ magicblock-account-cloner 3. `programs/magicblock/src/ephemeral_accounts` applies rent math using `EPHEMERAL_RENT_PER_BYTE` and account static-size overhead. 4. Processor tests assert sponsor/vault lamport movement, signer requirements, PDA sponsor rules, and close/resize behavior. -Changing the rent constant, vault pubkey, or account-meta shape affects user-visible balance semantics and tests. +`CreateRentPendingAta { wallet_owner, mint, token_program }` lives next to the +ephemeral-account instructions but creates a local canonical token ATA, not a +generic ephemeral account. Its account metas are payer signer, writable ATA, +mint, and token program. It reuses the former `Unused` bincode slot, so do not +insert another variant in that position or assume an unused discriminant remains +there. + +Changing the rent constant, vault pubkey, rent-pending ATA instruction shape, or account-meta shape affects user-visible balance semantics and tests. ### Task scheduling flow @@ -214,7 +221,9 @@ Do not add user-controlled signer bits to `ShortAccountMeta`; callback signer ha All major public structs/enums derive `Serialize` and `Deserialize` and are serialized with `bincode`. Enum variant order and struct field order matter. Adding fields without compatibility handling, removing variants, or reordering variants can break old transactions, persisted contexts, integration programs, or callback decoders. -The `Unused` instruction variant is intentionally retained as an unused slot after a removed `ScheduleCommitFinalize` path. Do not delete or repurpose it casually; doing so changes discriminants or semantics. +The former `Unused` instruction slot after the removed `ScheduleCommitFinalize` +path is now `CreateRentPendingAta`. Treat that discriminant as live wire/API +surface. Do not delete, reorder, or repurpose it casually. ### Secure vs legacy action scheduling diff --git a/.agents/specs/validator-specification.md b/.agents/specs/validator-specification.md index 71c4ff794..922dce415 100644 --- a/.agents/specs/validator-specification.md +++ b/.agents/specs/validator-specification.md @@ -329,6 +329,33 @@ Magic Program instructions: - `ResizeEphemeralAccount { new_data_len }` - `CloseEphemeralAccount` +## Rent-pending ATA materialization + +Rent-pending ATAs are local canonical token accounts that let the ER accept +tokens before the corresponding base-layer eATA exists. They are not generic +ephemeral accounts. The Magic Program creates them locally through +`CreateRentPendingAta { wallet_owner, mint, token_program }` and recognizes +them only when the local token account is canonical, owned by SPL Token or +Token-2022, non-native, delegated, not confined, not undelegating, not +ephemeral, and has close authority set to the rent sysvar sentinel. Ordinary +projected ATAs still use `Pubkey::default()` as the uncloseable close authority; +that marker is not rent-pending. + +New local rent-pending ATA creation is free, but the same transaction must leave +the newly created account with a positive token amount. The processor rejects +and rolls back a creation transaction that leaves a newly created rent-pending +ATA at zero balance. + +When a rent-pending ATA is scheduled for commit or commit-and-undelegate, the +Magic Program records explicit materialization metadata, charges the delegated +payer into the magic fee vault for each materialization, and still remaps the +committed ATA to its eATA form. The committor prepends e-token idempotent eATA +initialize and delegate-to-this-validator instructions before the normal DLP +commit task. If the eATA was created after local rent-pending creation and is +already delegated to another validator, the e-token delegation instruction is +the expected validator-mismatch failure gate; the DLP commit/undelegation must +not be treated as successful. + ## RPC and router specification The MagicBlock Router API implements most standard Solana JSON-RPC methods and adds MagicBlock-specific methods. diff --git a/Cargo.lock b/Cargo.lock index e4c21d437..64cd5aab6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3939,6 +3939,7 @@ dependencies = [ "solana-transaction-error 3.1.0", "solana-transaction-status", "solana-zk-elgamal-proof-program", + "spl-token-interface", "test-kit", "tokio", "tokio-util", @@ -3972,6 +3973,7 @@ dependencies = [ "solana-keypair", "solana-loader-v3-interface 6.1.1", "solana-loader-v4-interface 3.1.0", + "solana-program 3.0.0", "solana-program-runtime", "solana-pubkey 4.1.0", "solana-sdk-ids 3.1.0", @@ -3982,6 +3984,8 @@ dependencies = [ "solana-sysvar 3.1.1", "solana-transaction", "solana-transaction-context", + "spl-token-2022-interface", + "spl-token-interface", "test-kit", "thiserror 2.0.18", ] diff --git a/docs/mimd-rent-pending-ephemeral-ata.md b/docs/mimd-rent-pending-ephemeral-ata.md new file mode 100644 index 000000000..4461a6beb --- /dev/null +++ b/docs/mimd-rent-pending-ephemeral-ata.md @@ -0,0 +1,478 @@ +# Rent-Pending Ephemeral ATA Materialization + +Status: draft for review. + +This document specifies the validator-side protocol for a locally usable ATA +whose base-layer eATA does not exist yet. The user experience is "rent free" in +the ER, but base-layer eATA materialization still has explicit cost accounting. + +## Problem + +Today ATA/eATA commit assumes the companion eATA already exists on the base +layer and has the delegation metadata needed by the normal DLP commit flow. +That does not cover a gasless destination flow where tokens are sent to a +wallet before its base eATA exists. + +The missing-base case must not be forced through the normal DLP commit path: +there is no delegated base eATA yet and therefore no normal DLP commit nonce, +delegation metadata, or rent reimbursement state for that account until e-token +materializes it and delegates it to this validator. + +## Goals + +- Allow a canonical ATA to exist and be writable in the ER before its base eATA + exists. +- Define an explicit local creation path for rent-pending ATAs. +- Make the local token account explicitly recognizable as rent-pending. +- Keep local creation free to the user while keeping base materialization cost + explicit. +- Support legacy SPL Token and Token-2022 account layouts. +- Preserve Token-2022 account size and extension requirements. +- Make base eATA creation and delegation to this validator idempotent and + restart-recoverable. +- Reuse the existing commit/undelegation flow in the same base transaction + after the idempotent eATA materialization instruction runs. +- Avoid weakening signer, delegation, account-sync, or SVM writable-account + invariants. + +## Non-Goals + +- Do not make arbitrary ephemeral accounts commit to base. +- Do not create rent-pending ATAs through the generic ephemeral-account + lifecycle. +- Do not treat every projected ATA as rent-pending. +- Do not synthesize a legacy SPL Token layout for Token-2022 accounts. +- Do not support native SOL / wrapped SOL in this path until a separate + lamports-backed materialization design is approved. +- Do not use local account lamports as a rent-pending marker or close-safety + mechanism. +- Do not add a separate empty/create-only materialization path in V1; this path + exists to materialize eATA state needed by a normal commit or undelegation. +- Do not make local success look like base-layer completion before the + committor confirms the base transaction containing both materialization and + the normal commit/undelegation flow. + +## Terms + +- Base ATA: the canonical associated token account on Solana. +- eATA: the enhanced ATA PDA under `EATA_PROGRAM_ID`. +- Rent-pending ATA: a local canonical ATA that is writable in the ER and will be + mapped to an eATA after the eATA is materialized on base. +- Local creation instruction: a Magic Program instruction that initializes the + local canonical ATA in the ER without requiring a base-layer ATA/eATA to + already exist. +- Materialization instruction: an idempotent e-token instruction, or ordered + e-token instruction sequence, inserted into the same base transaction as the + normal commit or undelegation instruction. It must ensure the eATA exists and + is delegated to this validator before the DLP commit step for the affected + eATA. + +## Sentinel Close Authority + +Rent-pending ATAs use the Solana rent sysvar as token-account close authority: + +```rust +RENT_PENDING_ATA_CLOSE_AUTHORITY = solana_sysvar::rent::ID; +``` + +The rent sysvar is stable, easy to recognize, and not controlled by a normal +keypair. No validator, Magic Program, or e-token instruction may expose a path +that treats the rent sysvar as a signing authority for closing a local +rent-pending ATA. The local account must clear or replace the close authority +once it is no longer rent-pending. + +`Pubkey::default()` remains the marker for ordinary projected ATAs that are +locally uncloseable. It must not be interpreted as rent-pending. + +## Local Account Predicate + +An account is a rent-pending ATA candidate only when all checks pass: + +1. The local account pubkey is the canonical ATA for `(wallet_owner, + token_program, mint)`. +2. The account owner is `TOKEN_PROGRAM_ID` or `TOKEN_2022_PROGRAM_ID`. +3. Token account data parses successfully for that token program. +4. Token account `owner` and `mint` match the canonical ATA derivation. +5. Token account `close_authority` is + `Some(RENT_PENDING_ATA_CLOSE_AUTHORITY)`. +6. Local account flags have `delegated == true`. +7. Local account flags have `confined == false` and `undelegating == false`. +8. The account is not a native SOL / wrapped SOL token account. +9. The base eATA is missing, not delegated, or already delegated to this + validator with compatible state. An eATA delegated to another validator is + not a valid rent-pending target. + +This predicate is sufficient to enter the rent-pending protocol path. It is not +the complete materialization authority. Scheduling must convert the candidate +into explicit eATA materialization metadata before the committor sees it. + +V1 accepts only the delegated local token-account form. `delegated == true` is +the lifecycle flag that admits the account into this path; the `ephemeral` flag +is not an alternate admission path. + +## Local Account Shape + +For a non-native rent-pending ATA: + +- `lamports` is not part of the rent-pending predicate. +- `remote_slot` is `0` while local-only. +- `delegated` is `true`. +- `ephemeral` is `false` in the V1 protocol. +- `owner` is the token program. +- token amount is the local balance to commit after eATA materialization. +- close authority is the rent-pending sentinel. + +The local account is a token-program account, not a Magic Program ephemeral +account. The existing generic `ephemeral()` commit rejection stays in place. +The rent sysvar close authority makes the local ATA uncloseable by normal +users, so V1 does not need a zero-lamport local account convention for close +safety. + +## Local Creation Instruction + +Rent-pending ATAs are created locally through a dedicated Magic Program +instruction, not through the generic `CreateEphemeralAccount` path and not +through the base Associated Token Account Program. + +Tentative API: + +```rust +CreateRentPendingAta { + wallet_owner: Pubkey, + mint: Pubkey, + token_program: Pubkey, +} +``` + +Accounts: + +1. `[SIGNER]` payer or sponsor. This can be an on-curve signer or a PDA signer + through CPI. +2. `[WRITE]` canonical ATA PDA to initialize locally. +3. `[]` mint account. +4. `[]` token program, either `TOKEN_PROGRAM_ID` or `TOKEN_2022_PROGRAM_ID`. + +The recipient wallet owner is not required to sign. Unlike arbitrary +ephemeral-account creation, the account address is fixed by canonical ATA +derivation, and the initialized account owner is fixed to the token program. +The instruction may be invoked either top-level or by CPI. + +The processor must: + +1. Verify `token_program` is `TOKEN_PROGRAM_ID` or `TOKEN_2022_PROGRAM_ID`. +2. Verify the mint account is owned by `token_program` and is not the native + SOL / wrapped SOL mint. +3. Derive the canonical ATA from `(wallet_owner, token_program, mint)` and + require it equals the writable ATA account. +4. If the ATA account is locally empty/system-owned, initialize token-account + data directly: + - `mint = mint`; + - `owner = wallet_owner`; + - `amount = 0`; + - token account state is initialized; + - delegate is unset; + - native state is unset; + - close authority is `Some(RENT_PENDING_ATA_CLOSE_AUTHORITY)`. +5. Set local account metadata to the local rent-pending shape: + - `owner = token_program`; + - `delegated = true`; + - `ephemeral = false`; + - `confined = false`; + - `undelegating = false`; + - `remote_slot = 0`. +6. For Token-2022, compute the required token-account length from the mint + extensions and reject required account extensions that cannot be initialized + correctly in the ER. The implementation must not pack a Token-2022 account + into the legacy SPL Token layout. + +The instruction must be idempotent: + +- existing matching rent-pending ATA: success, preserving token balance and + existing account data except for re-normalizing the close-authority sentinel + if needed; +- existing delegated/projected ATA that is not rent-pending: success without + converting it to rent-pending; +- existing non-delegated, confined, undelegating, wrong-owner, wrong-mint, or + wrong-token-program account: fail. + +To avoid empty-account spam, V1 creation is only valid as part of a transaction +that actually gives the created rent-pending ATA a positive token balance. The +post-execution account verifier must reject and roll back any transaction that +creates a new rent-pending ATA and leaves it with `amount == 0`. Existing +matching rent-pending ATAs are not rejected merely because their current balance +is zero unless they were newly created in the same transaction. + +No local creation charge is required in V1. Base materialization cost remains +charged when the account is committed or undelegated. + +## Materialization Metadata + +Scheduling must emit a dedicated metadata record for each rent-pending ATA while +also keeping the account in the normal committed-account set: + +```rust +RentPendingAtaMaterialization { + ata_pubkey: Pubkey, + eata_pubkey: Pubkey, + token_program: Pubkey, + wallet_owner: Pubkey, + mint: Pubkey, + token_account_data_len: u64, + validator: Pubkey, + delegated_payer: Pubkey, + delegated_vault: Pubkey, +} +``` + +The metadata is the authority for adding the same-transaction +ensure-created-and-delegated materialization instruction. The close-authority +sentinel is only the local candidate marker used to build this metadata. + +The committed account itself remains the existing ATA account. The normal +`CommittedAccount::from_account_shared` ATA-to-eATA remap still produces the +eATA committed account and token amount used by the unchanged DLP commit flow +after materialization succeeds. + +## Scheduling Behavior + +When `ScheduleCommit`, `ScheduleCommitAndUndelegate`, +`ScheduleIntentBundle`, or equivalent scheduling code encounters a committed +account: + +1. Before ATA-to-eATA remapping erases token account fields, check whether the + account is a rent-pending ATA candidate. +2. If it is rent-pending, attach `RentPendingAtaMaterialization` metadata to the + scheduled intent and still build the normal committed account for it. The + normal committed account will remap ATA to eATA as it already does today. +3. Require the delegated payer path. The payer must be delegated and the magic + fee vault must be present, writable, and delegated. +4. Charge the delegated payer and credit lamports to the delegated vault for the + materialization cost in local state. Local ATA creation remains free; base + eATA materialization is not free. +5. Reject duplicate references where the same final eATA appears more than once + across the normal committed-account set and rent-pending metadata. + +The existing signer checks stay unchanged: + +- payer signer is still required; +- CPI parent/owner authority rules stay unchanged; +- validator-signed `AcceptScheduleCommits` stays required. + +## Committor Behavior + +The committor handles rent-pending metadata by adding materialization +instructions to the same base transaction that performs the normal commit or +undelegation: + +1. Detect rent-pending materialization metadata while building the base commit + or commit-and-undelegate transaction. +2. Build e-token idempotent eATA creation-plus-delegation instructions for all + rent-pending metadata entries in the intent. The target validator is the + validator recorded in the materialization metadata. +3. Insert those materialization instructions into the same transaction before + the normal DLP commit/undelegate instruction for the affected eATA. +4. Append/build the existing commit/finalize/undelegate instructions unchanged + after the materialization prefix. +5. Submit and confirm the single combined transaction with the same base-layer + confirmation guarantees used by other committor tasks. + +The desired V1 shape is atomic: if materialization fails, the normal +commit/undelegation does not execute; if commit/undelegation fails, +materialization is rolled back with the transaction. Retry resubmits the same +logical transaction and relies on both e-token creation and delegation being +idempotent. + +If an existing committor builder currently requires confirmed eATA delegation +state before it can construct the DLP commit instruction, the implementation +should first move that dependency to deterministic account derivation or a +reusable e-token/API builder contract. A separate pre-confirmed +materialization transaction is a fallback only if the same-transaction shape is +blocked by transaction size, compute budget, or a hard DLP protocol constraint. + +The e-token materialization instruction or instruction sequence must be +idempotent: + +- missing eATA: create, initialize, and delegate it to this validator for + `(wallet_owner, mint)`; +- existing matching eATA delegated to this validator: no-op success; +- existing matching eATA initialized but not delegated: delegate it to this + validator; +- existing matching eATA delegated to another validator or with incompatible + delegation metadata: fail with validator mismatch; +- existing mismatched eATA: fail with invalid base state. + +An eATA may be created and delegated to another validator after the local +rent-pending ATA is created but before commit or undelegation is submitted. +That race is an expected base-state conflict: the combined transaction must +fail with validator mismatch, the normal commit/undelegation must not be +treated as successful, and the committor must surface the failure instead of +silently clearing local rent-pending state. + +The combined materialization-plus-commit transaction must be persisted and +recoverable like other base-layer intent work. Retry must not create duplicate +state or double-debit the payer. + +## e-token Instruction Contract + +The validator must not fake eATA creation by writing through the DLP commit path +before the eATA exists. It needs an e-token/base-layer instruction with this +behavior: + +1. Create the canonical eATA idempotently if it is missing. +2. Verify mint, wallet owner, eATA address, validator, and token program. +3. Idempotently delegate the eATA to this validator if it is initialized but not + delegated. +4. Ensure the materialized eATA is in the exact state expected by the normal DLP + commit path for this validator. +5. Fail if the eATA is already delegated to another validator or has + incompatible delegation metadata. +6. Do not credit token amount directly. Token amount is committed by the normal + ATA-to-eATA commit flow after materialization. +7. Reject native SOL / wrapped SOL accounts. +8. Reject malformed Token-2022 account state or unsupported required + extensions. + +If the existing e-token idempotent creation path can already leave the eATA +delegated to the requested validator, reuse it. Otherwise, add the smallest +e-token builder/API surface needed for the committor to create and delegate +idempotently inside the same transaction before the unchanged commit path. + +The preferred reusable surfaces are: + +- the Rust `ephemeral-spl-api` crate from + `/Users/gabrielepicco/Documents/Solana/ephemeral-token/e-token-api`; +- existing Ephemeral Rollups SDK instruction/PDA builders, such as + `deriveEphemeralAta` and `delegateEphemeralAtaIx`, where the committor-side + integration is TypeScript-facing or can share that API contract. + +The validator should not hand-roll e-token instruction data or PDA derivation +when one of those surfaces can own it. If a new materialization builder is +needed, it should live in the e-token API crate and/or SDK first, then be reused +by the committor. + +## Chainlink and Projection Behavior + +ATA/eATA projection remains unchanged for real base ATAs: + +- projection requires a real base ATA layout; +- projection preserves token program owner, data length, and Token-2022 + extensions; +- projected ATAs use `close_authority = Some(Pubkey::default())`. + +Rent-pending ATAs are different: + +- a missing base ATA must not cause Chainlink to overwrite the local + rent-pending ATA with an empty placeholder; +- a later base eATA update that matches a completed materialization may replace + the local rent-pending state; +- a later mismatched base eATA update must mark the local state invalid or force + a refresh instead of silently projecting over it. + +## Invalid States + +Reject or fail materialization for: + +- sentinel close authority on a non-canonical ATA; +- sentinel close authority on an undelegated account; +- newly created rent-pending ATA that still has zero token balance at the end + of its creation transaction; +- sentinel close authority on a confined or undelegating account; +- sentinel close authority on native SOL / wrapped SOL; +- token account data that does not parse for the declared token program; +- Token-2022 account data that would require legacy SPL packing; +- base eATA state that does not match the metadata; +- base eATA already delegated to a different validator. + +## Security Notes + +The sentinel close authority is not a signer grant. It is a data marker. The +protocol decision is: + +```text +delegated local canonical ATA ++ rent-pending close-authority sentinel ++ structural token validation +=> build explicit rent-pending materialization metadata +``` + +Only the explicit metadata may drive committor materialization. This prevents a +locally forged close-authority field from bypassing payer/vault accounting, +base-state checks, idempotency, or token-program validation. + +No path may relax: + +- Magic Program payer signature checks; +- CPI parent/owner authorization; +- validator-signed acceptance of scheduled intents; +- SVM writable-account access validation; +- base-layer freshness checks; +- committor conflict scheduling and restart recovery. + +## Validation Plan + +Focused unit tests: + +```bash +cargo test -p magicblock-core rent_pending --lib +cargo test -p magicblock-program rent_pending --lib +cargo test -p magicblock-committor-service rent_pending --lib +cargo test -p magicblock-chainlink rent_pending --lib +cargo test -p magicblock-processor rent_pending --test ephemeral_accounts +``` + +Required scenario coverage: + +- local creation initializes a rent-pending ATA as `delegated == true` and + `ephemeral == false`; +- creation transaction rolls back when a newly created rent-pending ATA ends + with zero token balance; +- commit of a rent-pending ATA prepends idempotent eATA + creation-plus-delegation to the same base transaction; +- undelegation of a rent-pending ATA prepends idempotent eATA + creation-plus-delegation to the same base transaction; +- if the eATA is created after local rent-pending ATA creation and delegated to + another validator before commit, commit fails with validator mismatch and + does not clear local rent-pending state as successfully committed; +- if the eATA is created after local rent-pending ATA creation and delegated to + another validator before undelegation, undelegation fails with validator + mismatch and does not clear local rent-pending state as successfully + undelegated. + +Repository checks before merge: + +```bash +make fmt +make lint +``` + +Integration coverage after implementation: + +```bash +cd test-integration +make test-chainlink +make test-committor +make test-schedule-intents +``` + +The implementation handoff must report whether performance-sensitive paths were +measured or only reasoned about. The expected hot-path impact should be limited +to constant-time candidate detection during scheduling/projection plus a +same-transaction materialization instruction prefix for intents that actually +include rent-pending ATAs. + +## Resolved V1 Decisions + +1. V1 accepts only `delegated == true` local token accounts for this path. +2. V1 does not require a zero-lamport local ATA convention and does not add a + separate empty/create-only materialization flow. +3. The e-token materialization instruction surface should be reused from + `ephemeral-spl-api` and/or the Ephemeral Rollups SDK instead of being + hand-rolled in the validator. +4. The preferred V1 committor shape is one combined base transaction: e-token + idempotent creation-plus-delegation first, normal DLP commit/undelegation + second. +5. Rent-pending ATAs use a dedicated Magic Program local creation instruction, + stay `ephemeral == false`, and are valid only when the creation transaction + leaves the new account with a positive token balance. +6. Materialization must be both idempotent creation and idempotent delegation to + this validator; create-only materialization is insufficient. diff --git a/magicblock-committor-service/src/persist/commit_persister.rs b/magicblock-committor-service/src/persist/commit_persister.rs index 375dd7f59..f940e6c54 100644 --- a/magicblock-committor-service/src/persist/commit_persister.rs +++ b/magicblock-committor-service/src/persist/commit_persister.rs @@ -665,6 +665,7 @@ mod tests { commit_finalize: None, commit_finalize_and_undelegate: None, standalone_actions: vec![], + rent_pending_ata_materializations: vec![], } } @@ -678,6 +679,7 @@ mod tests { commit_finalize: None, commit_finalize_and_undelegate: None, standalone_actions: vec![], + rent_pending_ata_materializations: vec![], } } @@ -691,6 +693,7 @@ mod tests { commit_finalize: None, commit_finalize_and_undelegate: None, standalone_actions: vec![], + rent_pending_ata_materializations: vec![], } } @@ -701,6 +704,7 @@ mod tests { commit_finalize: None, commit_finalize_and_undelegate: None, standalone_actions: vec![], + rent_pending_ata_materializations: vec![], } } diff --git a/magicblock-committor-service/src/tasks/mod.rs b/magicblock-committor-service/src/tasks/mod.rs index 7640ff74e..9c8b81108 100644 --- a/magicblock-committor-service/src/tasks/mod.rs +++ b/magicblock-committor-service/src/tasks/mod.rs @@ -1,4 +1,12 @@ -use dlp_api::{args::CallHandlerArgs, AccountSizeClass}; +use dlp_api::{ + args::CallHandlerArgs, + pda::{ + delegate_buffer_pda_from_delegated_account_and_owner_program, + delegation_metadata_pda_from_delegated_account, + delegation_record_pda_from_delegated_account, + }, + AccountSizeClass, +}; use magicblock_committor_program::{ instruction_builder::{ close_buffer::{create_close_ix, CreateCloseIxArgs}, @@ -10,7 +18,10 @@ use magicblock_committor_program::{ }, pdas, ChangesetChunks, Chunks, }; -use magicblock_core::intent::BaseActionCallback; +use magicblock_core::{ + intent::BaseActionCallback, + token_programs::{RentPendingAtaMaterialization, EATA_PROGRAM_ID}, +}; use magicblock_metrics::metrics::LabelValue; use magicblock_program::magic_scheduled_base_intent::BaseAction; use solana_instruction::{AccountMeta, Instruction}; @@ -45,6 +56,8 @@ pub enum TaskStrategy { #[derive(Clone, Debug)] pub enum BaseTaskImpl { + InitializeRentPendingAta(RentPendingAtaTask), + DelegateRentPendingAta(RentPendingAtaTask), Commit(CommitTask), CommitFinalize(CommitFinalizeTask), Finalize(FinalizeTask), @@ -54,11 +67,25 @@ pub enum BaseTaskImpl { impl BaseTask for BaseTaskImpl { fn program_id(&self) -> Pubkey { - dlp_api::id() + match self { + Self::InitializeRentPendingAta(value) + | Self::DelegateRentPendingAta(value) => value.program_id(), + Self::Commit(value) => value.program_id(), + Self::CommitFinalize(value) => value.program_id(), + Self::Finalize(_) | Self::Undelegate(_) | Self::BaseAction(_) => { + dlp_api::id() + } + } } fn instruction(&self, validator: &Pubkey) -> Instruction { match self { + Self::InitializeRentPendingAta(value) => { + value.initialize_instruction(validator) + } + Self::DelegateRentPendingAta(value) => { + value.delegate_instruction(validator) + } Self::Commit(value) => value.instruction(validator), Self::CommitFinalize(value) => value.instruction(validator), Self::Finalize(value) => value.instruction(validator), @@ -79,6 +106,8 @@ impl BaseTask for BaseTaskImpl { match self { Self::Commit(value) => value.compute_units(), Self::CommitFinalize(value) => value.compute_units(), + Self::InitializeRentPendingAta(_) => 80_000, + Self::DelegateRentPendingAta(_) => 120_000, Self::BaseAction(value) => value.compute_units(), Self::Finalize(_) => 120_000, Self::Undelegate(_) => 120_000, @@ -89,6 +118,8 @@ impl BaseTask for BaseTaskImpl { match self { Self::Commit(value) => value.accounts_size_budget(), Self::CommitFinalize(value) => value.accounts_size_budget(), + Self::InitializeRentPendingAta(_) + | Self::DelegateRentPendingAta(_) => 0, Self::BaseAction(value) => value.accounts_size_budget(), Self::Finalize(_) => { dlp_api::instruction_builder::finalize_size_budget( @@ -107,6 +138,8 @@ impl BaseTask for BaseTaskImpl { impl BaseTaskImpl { pub fn strategy(&self) -> TaskStrategy { match self { + Self::InitializeRentPendingAta(_) + | Self::DelegateRentPendingAta(_) => TaskStrategy::Args, Self::Commit(task) if task.is_buffer() => TaskStrategy::Buffer, Self::CommitFinalize(task) if task.is_buffer() => { TaskStrategy::Buffer @@ -119,6 +152,10 @@ impl BaseTaskImpl { impl LabelValue for BaseTaskImpl { fn value(&self) -> &str { match self { + Self::InitializeRentPendingAta(_) => { + "args_initialize_rent_pending_ata" + } + Self::DelegateRentPendingAta(_) => "args_delegate_rent_pending_ata", Self::Commit(task) => { if task.is_buffer() { "buffer_commit" @@ -141,6 +178,70 @@ impl LabelValue for BaseTaskImpl { } } +#[derive(Clone, Debug)] +pub struct RentPendingAtaTask { + pub materialization: RentPendingAtaMaterialization, +} + +impl RentPendingAtaTask { + const INITIALIZE_EPHEMERAL_ATA_IX: u8 = 0; + const DELEGATE_EPHEMERAL_ATA_IX: u8 = 4; + + fn program_id(&self) -> Pubkey { + EATA_PROGRAM_ID + } + + fn initialize_instruction(&self, validator: &Pubkey) -> Instruction { + let materialization = &self.materialization; + Instruction { + program_id: EATA_PROGRAM_ID, + accounts: vec![ + AccountMeta::new(materialization.eata_pubkey, false), + AccountMeta::new(*validator, true), + AccountMeta::new_readonly(materialization.wallet_owner, false), + AccountMeta::new_readonly(materialization.mint, false), + AccountMeta::new_readonly(solana_system_program::id(), false), + ], + data: vec![Self::INITIALIZE_EPHEMERAL_ATA_IX], + } + } + + fn delegate_instruction(&self, validator: &Pubkey) -> Instruction { + let materialization = &self.materialization; + let buffer_pda = + delegate_buffer_pda_from_delegated_account_and_owner_program( + &materialization.eata_pubkey, + &EATA_PROGRAM_ID, + ); + let delegation_record_pda = + delegation_record_pda_from_delegated_account( + &materialization.eata_pubkey, + ); + let delegation_metadata_pda = + delegation_metadata_pda_from_delegated_account( + &materialization.eata_pubkey, + ); + let mut data = Vec::with_capacity(33); + data.push(Self::DELEGATE_EPHEMERAL_ATA_IX); + data.extend_from_slice(validator.as_ref()); + + Instruction { + program_id: EATA_PROGRAM_ID, + accounts: vec![ + AccountMeta::new(*validator, true), + AccountMeta::new(materialization.eata_pubkey, false), + AccountMeta::new_readonly(EATA_PROGRAM_ID, false), + AccountMeta::new(buffer_pda, false), + AccountMeta::new(delegation_record_pda, false), + AccountMeta::new(delegation_metadata_pda, false), + AccountMeta::new_readonly(dlp_api::id(), false), + AccountMeta::new_readonly(solana_system_program::id(), false), + ], + data, + } + } +} + /// A trait representing a task that can be executed on Base layer pub trait BaseTask: Send + Sync + Clone { /// Gets all pubkeys that involved in Task's instruction diff --git a/magicblock-committor-service/src/tasks/task_builder.rs b/magicblock-committor-service/src/tasks/task_builder.rs index 515e925f5..26c6a48a3 100644 --- a/magicblock-committor-service/src/tasks/task_builder.rs +++ b/magicblock-committor-service/src/tasks/task_builder.rs @@ -20,7 +20,7 @@ use crate::{ tasks::{ commit_task::{CommitDelivery, CommitTask}, BaseActionTask, BaseActionTaskV1, BaseActionTaskV2, BaseTaskImpl, - CommitFinalizeTask, FinalizeTask, UndelegateTask, + CommitFinalizeTask, FinalizeTask, RentPendingAtaTask, UndelegateTask, }, }; @@ -143,7 +143,17 @@ impl TaskBuilderImpl { persister: &Option

, ) -> TaskBuilderResult { // Fetch necessary data for BaseTasks creation - let all_committed_accounts = intent_bundle.get_all_committed_accounts(); + let rent_pending_pubkeys = intent_bundle + .intent_bundle + .rent_pending_ata_materializations + .iter() + .map(|materialization| materialization.eata_pubkey) + .collect::>(); + let all_committed_accounts = intent_bundle + .get_all_committed_accounts() + .into_iter() + .filter(|account| !rent_pending_pubkeys.contains(&account.pubkey)) + .collect::>(); // Get commit nonces and base accounts let min_context_slot = all_committed_accounts .iter() @@ -162,8 +172,14 @@ impl TaskBuilderImpl { min_context_slot ) ); - let commit_nonces = + let mut commit_nonces = commit_ids.map_err(TaskBuilderError::CommitTasksBuildError)?; + for materialization in &intent_bundle + .intent_bundle + .rent_pending_ata_materializations + { + commit_nonces.insert(materialization.eata_pubkey, 1); + } let base_accounts = base_accounts.unwrap_or_else(|err| { tracing::warn!(intent_id = intent_bundle.id, error = ?err, "Failed to fetch base accounts, falling back to CommitState"); Default::default() @@ -237,6 +253,22 @@ impl TasksBuilder for TaskBuilderImpl { ) .await?; + tasks.extend( + intent_bundle + .intent_bundle + .rent_pending_ata_materializations + .iter() + .flat_map(|materialization| { + let task = RentPendingAtaTask { + materialization: materialization.clone(), + }; + [ + BaseTaskImpl::InitializeRentPendingAta(task.clone()), + BaseTaskImpl::DelegateRentPendingAta(task), + ] + }), + ); + // Create tasks per intent type if let Some(ref value) = intent_bundle.intent_bundle.commit { tasks.extend( @@ -541,3 +573,219 @@ impl TaskBuilderError { } pub type TaskBuilderResult = Result; + +#[cfg(test)] +mod tests { + use magicblock_core::token_programs::{ + EphemeralAta, RentPendingAtaMaterialization, EATA_PROGRAM_ID, + TOKEN_PROGRAM_ID, + }; + use magicblock_program::magic_scheduled_base_intent::{ + MagicIntentBundle, ScheduledIntentBundle, + }; + use solana_hash::Hash; + use solana_transaction::Transaction; + + use super::*; + use crate::tasks::BaseTask; + + struct EmptyFetcher; + + #[async_trait] + impl TaskInfoFetcher for EmptyFetcher { + async fn fetch_next_commit_nonces( + &self, + pubkeys: &[Pubkey], + _min_context_slot: u64, + ) -> TaskInfoFetcherResult> { + assert!(pubkeys.is_empty()); + Ok(HashMap::new()) + } + + async fn fetch_current_commit_nonces( + &self, + pubkeys: &[Pubkey], + _min_context_slot: u64, + ) -> TaskInfoFetcherResult> { + assert!(pubkeys.is_empty()); + Ok(HashMap::new()) + } + + async fn fetch_rent_reimbursements( + &self, + _pubkeys: &[Pubkey], + _min_context_slot: u64, + ) -> TaskInfoFetcherResult> { + Ok(Vec::new()) + } + + async fn get_base_accounts( + &self, + pubkeys: &[Pubkey], + _min_context_slot: u64, + ) -> TaskInfoFetcherResult> { + assert!(pubkeys.is_empty()); + Ok(HashMap::new()) + } + } + + fn rent_pending_intent( + commit_and_undelegate: bool, + ) -> (ScheduledIntentBundle, Pubkey) { + let wallet_owner = Pubkey::new_unique(); + let mint = Pubkey::new_unique(); + let eata_pubkey = Pubkey::new_unique(); + let validator = Pubkey::new_unique(); + let eata = EphemeralAta { + owner: wallet_owner, + mint, + amount: 7, + bump: 255, + }; + let committed_account = CommittedAccount { + pubkey: eata_pubkey, + account: eata.into(), + remote_slot: 0, + }; + let materialization = RentPendingAtaMaterialization { + ata_pubkey: Pubkey::new_unique(), + eata_pubkey, + token_program: TOKEN_PROGRAM_ID, + wallet_owner, + mint, + token_account_data_len: 165, + validator, + delegated_payer: Pubkey::new_unique(), + delegated_vault: Pubkey::new_unique(), + }; + let intent_bundle = if commit_and_undelegate { + MagicIntentBundle { + commit_and_undelegate: Some(CommitAndUndelegate { + commit_action: CommitType::Standalone(vec![ + committed_account, + ]), + undelegate_action: UndelegateType::Standalone, + }), + rent_pending_ata_materializations: vec![materialization], + ..Default::default() + } + } else { + MagicIntentBundle { + commit: Some(CommitType::Standalone(vec![committed_account])), + rent_pending_ata_materializations: vec![materialization], + ..Default::default() + } + }; + + ( + ScheduledIntentBundle { + id: 42, + slot: 0, + blockhash: Hash::default(), + sent_transaction: Transaction::default(), + payer: Pubkey::new_unique(), + intent_bundle, + }, + validator, + ) + } + + #[tokio::test] + async fn rent_pending_commit_prepends_eata_materialization() { + let fetcher = Arc::new(EmptyFetcher); + let (intent, validator) = rent_pending_intent(false); + + let tasks = TaskBuilderImpl::commit_tasks( + &fetcher, + &intent, + &None::, + ) + .await + .unwrap(); + + assert!(matches!( + tasks.as_slice(), + [ + BaseTaskImpl::InitializeRentPendingAta(_), + BaseTaskImpl::DelegateRentPendingAta(_), + BaseTaskImpl::Commit(_) + ] + )); + let delegate_ix = tasks[1].instruction(&validator); + assert_eq!(delegate_ix.program_id, EATA_PROGRAM_ID); + assert_eq!(delegate_ix.data[0], 4); + assert_eq!(&delegate_ix.data[1..], validator.as_ref()); + let BaseTaskImpl::Commit(commit) = &tasks[2] else { + panic!("expected commit task"); + }; + assert_eq!(commit.commit_id, 1); + } + + #[tokio::test] + async fn rent_pending_undelegation_prepends_validator_specific_eata_delegate( + ) { + let fetcher = Arc::new(EmptyFetcher); + let (intent, validator) = rent_pending_intent(true); + + let tasks = TaskBuilderImpl::commit_tasks( + &fetcher, + &intent, + &None::, + ) + .await + .unwrap(); + + assert!(matches!( + tasks.as_slice(), + [ + BaseTaskImpl::InitializeRentPendingAta(_), + BaseTaskImpl::DelegateRentPendingAta(_), + BaseTaskImpl::Commit(_) + ] + )); + let delegate_ix = tasks[1].instruction(&validator); + assert_eq!(delegate_ix.data[0], 4); + assert_eq!(&delegate_ix.data[1..], validator.as_ref()); + } + + #[tokio::test] + async fn rent_pending_conflicting_base_delegation_is_gated_by_eata_delegate( + ) { + let fetcher = Arc::new(EmptyFetcher); + + for commit_and_undelegate in [false, true] { + let (intent, validator) = + rent_pending_intent(commit_and_undelegate); + let eata_pubkey = + intent.intent_bundle.rent_pending_ata_materializations[0] + .eata_pubkey; + + let tasks = TaskBuilderImpl::commit_tasks( + &fetcher, + &intent, + &None::, + ) + .await + .unwrap(); + + // If base creates and delegates the eATA to another validator after + // local rent-pending creation, e-token delegation is the failing + // validator-mismatch gate and must precede DLP commit work. + assert!(matches!( + tasks.as_slice(), + [ + BaseTaskImpl::InitializeRentPendingAta(_), + BaseTaskImpl::DelegateRentPendingAta(_), + BaseTaskImpl::Commit(_) + ] + )); + let delegate_ix = tasks[1].instruction(&validator); + assert_eq!(delegate_ix.program_id, EATA_PROGRAM_ID); + assert_eq!(delegate_ix.accounts[0].pubkey, validator); + assert!(delegate_ix.accounts[0].is_signer); + assert_eq!(delegate_ix.accounts[1].pubkey, eata_pubkey); + assert_eq!(delegate_ix.data[0], 4); + assert_eq!(&delegate_ix.data[1..], validator.as_ref()); + } + } +} diff --git a/magicblock-core/Cargo.toml b/magicblock-core/Cargo.toml index fe621634b..e1cd89dff 100644 --- a/magicblock-core/Cargo.toml +++ b/magicblock-core/Cargo.toml @@ -17,7 +17,7 @@ bytes = { workspace = true } serde = { workspace = true, features = ["derive"] } solana-account = { workspace = true } -solana-account-decoder = { workspace = true } +solana-account-decoder = { workspace = true, features = ["agave-unstable-api"] } solana-clock = { workspace = true } solana-hash = { workspace = true } solana-message = { workspace = true } @@ -27,7 +27,7 @@ solana-signature = { workspace = true } solana-transaction = { workspace = true, features = ["blake3", "verify"] } solana-transaction-context = { workspace = true } solana-transaction-error = { workspace = true } -solana-transaction-status-client-types = { workspace = true } +solana-transaction-status-client-types = { workspace = true, features = ["agave-unstable-api"] } magicblock-magic-program-api = { workspace = true } spl-token = { workspace = true } spl-token-2022 = { workspace = true } diff --git a/magicblock-core/src/tls.rs b/magicblock-core/src/tls.rs index 9f30178b3..b42515449 100644 --- a/magicblock-core/src/tls.rs +++ b/magicblock-core/src/tls.rs @@ -1,10 +1,12 @@ use std::{cell::RefCell, collections::VecDeque}; use magicblock_magic_program_api::args::TaskRequest; +use solana_pubkey::Pubkey; #[derive(Default, Debug)] pub struct ExecutionTlsStash { tasks: VecDeque, + created_rent_pending_atas: VecDeque, // TODO(bmuddha/taco-paco): intents should go in here intents: VecDeque<()>, } @@ -23,9 +25,22 @@ impl ExecutionTlsStash { EXECUTION_TLS_STASH.with_borrow_mut(|stash| stash.tasks.pop_front()) } + pub fn register_created_rent_pending_ata(pubkey: Pubkey) { + EXECUTION_TLS_STASH.with_borrow_mut(|stash| { + stash.created_rent_pending_atas.push_back(pubkey) + }); + } + + pub fn next_created_rent_pending_ata() -> Option { + EXECUTION_TLS_STASH.with_borrow_mut(|stash| { + stash.created_rent_pending_atas.pop_front() + }) + } + pub fn clear() { EXECUTION_TLS_STASH.with_borrow_mut(|stash| { stash.tasks.clear(); + stash.created_rent_pending_atas.clear(); stash.intents.clear(); }) } diff --git a/magicblock-core/src/token_programs.rs b/magicblock-core/src/token_programs.rs index ad9210202..a9ebc7b50 100644 --- a/magicblock-core/src/token_programs.rs +++ b/magicblock-core/src/token_programs.rs @@ -1,3 +1,4 @@ +use serde::{Deserialize, Serialize}; use solana_account::{ Account, AccountSharedData, ReadableAccount, WritableAccount, }; @@ -5,7 +6,8 @@ use solana_program::{program_option::COption, program_pack::Pack, rent::Rent}; use solana_pubkey::{pubkey, Pubkey}; use spl_token::state::Account as SplAccount; use spl_token_2022::{ - extension::StateWithExtensionsMut, state::Account as Token2022Account, + extension::{StateWithExtensions, StateWithExtensionsMut}, + state::Account as Token2022Account, }; // Shared program IDs and helper functions for SPL Token, Associated Token, and eATA programs. @@ -25,10 +27,44 @@ pub const ASSOCIATED_TOKEN_PROGRAM_ID: Pubkey = // Enhanced ATA (eATA) Program ID (SPLxh1...) pub const EATA_PROGRAM_ID: Pubkey = pubkey!("SPLxh1LVZzEkX99H6rqYizhytLWPZVV296zyYDPagv2"); +pub const RENT_PENDING_ATA_CLOSE_AUTHORITY: Pubkey = + solana_program::sysvar::rent::ID; pub const EPHEMERAL_ATA_LEN: usize = 80; const LEGACY_EPHEMERAL_ATA_LEN: usize = 72; +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RentPendingAtaMaterialization { + pub ata_pubkey: Pubkey, + pub eata_pubkey: Pubkey, + pub token_program: Pubkey, + pub wallet_owner: Pubkey, + pub mint: Pubkey, + pub token_account_data_len: u64, + pub validator: Pubkey, + pub delegated_payer: Pubkey, + pub delegated_vault: Pubkey, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RentPendingAtaInfo { + pub ata_pubkey: Pubkey, + pub eata_pubkey: Pubkey, + pub token_program: Pubkey, + pub wallet_owner: Pubkey, + pub mint: Pubkey, + pub amount: u64, + pub token_account_data_len: u64, +} + +struct ParsedTokenAccount { + mint: Pubkey, + owner: Pubkey, + amount: u64, + is_native: COption, + close_authority: COption, +} + /// Private WSOL is represented locally by the token amount that maps to eATA, /// not by claimable lamports on the projected ATA. /// Returns false when token-program data is malformed and a caller that already @@ -389,6 +425,90 @@ pub fn try_remap_ata_to_eata( Some((eata_pubkey, eata)) } +pub fn try_get_rent_pending_ata_info( + pubkey: &Pubkey, + account: &AccountSharedData, +) -> Option { + if !account.delegated() + || account.ephemeral() + || account.confined() + || account.undelegating() + { + return None; + } + + let token_program_owner = account.owner(); + let is_spl_token = *token_program_owner == TOKEN_PROGRAM_ID; + let is_token_2022 = *token_program_owner == TOKEN_2022_PROGRAM_ID; + if !(is_spl_token || is_token_2022) { + return None; + } + + let token_account = parse_token_account_for_rent_pending( + token_program_owner, + account.data(), + )?; + if token_account.close_authority + != COption::Some(RENT_PENDING_ATA_CLOSE_AUTHORITY) + || token_account.is_native.is_some() + { + return None; + } + + let expected_ata = derive_ata_with_token_program( + &token_account.owner, + &token_account.mint, + token_program_owner, + ); + if expected_ata != *pubkey { + return None; + } + + let (eata_pubkey, _) = try_derive_eata_address_and_bump( + &token_account.owner, + &token_account.mint, + )?; + + Some(RentPendingAtaInfo { + ata_pubkey: *pubkey, + eata_pubkey, + token_program: *token_program_owner, + wallet_owner: token_account.owner, + mint: token_account.mint, + amount: token_account.amount, + token_account_data_len: account.data().len() as u64, + }) +} + +fn parse_token_account_for_rent_pending( + token_program: &Pubkey, + data: &[u8], +) -> Option { + if *token_program == TOKEN_PROGRAM_ID { + let account = SplAccount::unpack(data).ok()?; + Some(ParsedTokenAccount { + mint: account.mint, + owner: account.owner, + amount: account.amount, + is_native: account.is_native, + close_authority: account.close_authority, + }) + } else if *token_program == TOKEN_2022_PROGRAM_ID { + let account = StateWithExtensions::::unpack(data) + .ok()? + .base; + Some(ParsedTokenAccount { + mint: account.mint, + owner: account.owner, + amount: account.amount, + is_native: account.is_native, + close_authority: account.close_authority, + }) + } else { + None + } +} + // ---------------- eATA -> ATA projection helpers ---------------- /// Minimal ephemeral representation of an SPL token account used to build @@ -638,4 +758,55 @@ mod tests { COption::Some(Pubkey::default()) ); } + + #[test] + fn rent_pending_ata_uses_rent_sysvar_close_authority() { + let wallet_owner = Pubkey::new_unique(); + let mint = Pubkey::new_unique(); + let ata = derive_ata(&wallet_owner, &mint); + let token_account = SplAccount { + mint, + owner: wallet_owner, + amount: 9, + delegate: COption::None, + state: AccountState::Initialized, + is_native: COption::None, + delegated_amount: 0, + close_authority: COption::Some(RENT_PENDING_ATA_CLOSE_AUTHORITY), + }; + + let mut data = vec![0u8; SplAccount::LEN]; + SplAccount::pack(token_account, &mut data).unwrap(); + let mut account = AccountSharedData::from(Account { + owner: TOKEN_PROGRAM_ID, + data, + lamports: Rent::default().minimum_balance(SplAccount::LEN), + executable: false, + ..Default::default() + }); + account.set_delegated(true); + + let info = try_get_rent_pending_ata_info(&ata, &account) + .expect("rent-pending ATA should be detected"); + assert_eq!(info.ata_pubkey, ata); + assert_eq!(info.wallet_owner, wallet_owner); + assert_eq!(info.mint, mint); + assert_eq!(info.amount, 9); + + let mut default_close_authority = account.clone(); + let mut token = + SplAccount::unpack(default_close_authority.data()).unwrap(); + token.close_authority = COption::Some(Pubkey::default()); + SplAccount::pack(token, default_close_authority.data_as_mut_slice()) + .unwrap(); + assert!( + try_get_rent_pending_ata_info(&ata, &default_close_authority) + .is_none() + ); + + let mut undelegating_account = account.clone(); + undelegating_account.set_undelegating(true); + assert!(try_get_rent_pending_ata_info(&ata, &undelegating_account) + .is_none()); + } } diff --git a/magicblock-magic-program-api/src/instruction.rs b/magicblock-magic-program-api/src/instruction.rs index 208db91f6..78f08d97a 100644 --- a/magicblock-magic-program-api/src/instruction.rs +++ b/magicblock-magic-program-api/src/instruction.rs @@ -184,12 +184,18 @@ pub enum MagicBlockInstruction { /// - **2.** `[WRITE]` Vault account (source of rent refund) CloseEphemeralAccount, - /// Unsed instruction slot. - /// -- can be repurposed -- - /// This variant was originally used for `ScheduleCommitFinalize`, but that - /// instruction was removed. It is intentionally left unused so the wire - /// discriminant can be repurposed in a future protocol update. - Unused, + /// Creates or verifies a local rent-pending projected ATA. + /// + /// # Account references + /// - **0.** `[SIGNER]` Payer/sponsor + /// - **1.** `[WRITE]` Canonical ATA PDA + /// - **2.** `[]` Mint + /// - **3.** `[]` Token program + CreateRentPendingAta { + wallet_owner: Pubkey, + mint: Pubkey, + token_program: Pubkey, + }, /// Clone a single account that fits in one transaction (<63KB data). /// diff --git a/magicblock-processor/Cargo.toml b/magicblock-processor/Cargo.toml index 60d25ce7b..02b4fea31 100644 --- a/magicblock-processor/Cargo.toml +++ b/magicblock-processor/Cargo.toml @@ -59,4 +59,5 @@ solana-compute-budget-interface = { workspace = true } solana-keypair = { workspace = true } solana-signature = { workspace = true } solana-signer = { workspace = true } +spl-token = { workspace = true } test-kit = { workspace = true } diff --git a/magicblock-processor/src/executor/processing.rs b/magicblock-processor/src/executor/processing.rs index a75315637..5399145e4 100644 --- a/magicblock-processor/src/executor/processing.rs +++ b/magicblock-processor/src/executor/processing.rs @@ -11,6 +11,7 @@ use magicblock_core::{ }, }, tls::ExecutionTlsStash, + token_programs::try_get_rent_pending_ata_info, }; use magicblock_metrics::metrics::{ FAILED_TRANSACTIONS_COUNT, TRANSACTION_COUNT, @@ -458,6 +459,41 @@ impl super::TransactionExecutor { break; } } + + if executed.execution_details.status.is_err() { + return; + } + + while let Some(pubkey) = + ExecutionTlsStash::next_created_rent_pending_ata() + { + let Some((_, acc)) = + txn.accounts.iter().find(|(key, _)| key == &pubkey) + else { + executed.execution_details.status = + Err(TransactionError::UnbalancedTransaction); + logs.push(format!( + "Rent-pending ATA {pubkey} was not available after creation" + )); + break; + }; + let Some(info) = try_get_rent_pending_ata_info(&pubkey, acc) else { + executed.execution_details.status = + Err(TransactionError::UnbalancedTransaction); + logs.push(format!( + "Rent-pending ATA {pubkey} has invalid post-creation state" + )); + break; + }; + if info.amount == 0 { + executed.execution_details.status = + Err(TransactionError::UnbalancedTransaction); + logs.push(format!( + "Rent-pending ATA {pubkey} must end creation transaction with positive token amount" + )); + break; + } + } } fn compute_budget_limits( diff --git a/magicblock-processor/tests/ephemeral_accounts.rs b/magicblock-processor/tests/ephemeral_accounts.rs index b072ca8f5..65988ee58 100644 --- a/magicblock-processor/tests/ephemeral_accounts.rs +++ b/magicblock-processor/tests/ephemeral_accounts.rs @@ -1,13 +1,21 @@ use guinea::GuineaInstruction; use magicblock_accounts_db::traits::AccountsBank; +use magicblock_core::token_programs::{derive_ata, TOKEN_PROGRAM_ID}; use magicblock_magic_program_api::{ instruction::MagicBlockInstruction, EPHEMERAL_RENT_PER_BYTE, EPHEMERAL_VAULT_PUBKEY, ID as MAGIC_PROGRAM_ID, }; -use solana_account::{AccountSharedData, ReadableAccount, WritableAccount}; +use solana_account::{ + Account, AccountSharedData, ReadableAccount, WritableAccount, +}; use solana_keypair::Keypair; -use solana_program::instruction::{AccountMeta, Instruction}; +use solana_program::{ + instruction::{AccountMeta, Instruction}, + program_option::COption, + program_pack::Pack, +}; use solana_pubkey::Pubkey; +use spl_token::state::Mint as SplMint; use test_kit::{ExecutionTestEnv, Signer}; /// Calculates rent for an ephemeral account (same logic as magic program) @@ -103,6 +111,49 @@ fn create_ephemeral_account_ix( ) } +fn init_spl_mint(env: &ExecutionTestEnv, mint: Pubkey) { + let mint_state = SplMint { + mint_authority: COption::None, + supply: 0, + decimals: 6, + is_initialized: true, + freeze_authority: COption::None, + }; + let mut data = vec![0; SplMint::LEN]; + SplMint::pack(mint_state, &mut data).unwrap(); + let mut account = AccountSharedData::from(Account { + lamports: 1_000_000, + data, + owner: TOKEN_PROGRAM_ID, + executable: false, + rent_epoch: 0, + }); + account.set_delegated(true); + env.accountsdb.insert_account(&mint, &account).unwrap(); +} + +fn create_rent_pending_ata_ix( + payer: Pubkey, + wallet_owner: Pubkey, + mint: Pubkey, +) -> Instruction { + let ata = derive_ata(&wallet_owner, &mint); + Instruction::new_with_bincode( + MAGIC_PROGRAM_ID, + &MagicBlockInstruction::CreateRentPendingAta { + wallet_owner, + mint, + token_program: TOKEN_PROGRAM_ID, + }, + vec![ + AccountMeta::new(payer, true), + AccountMeta::new(ata, false), + AccountMeta::new_readonly(mint, false), + AccountMeta::new_readonly(TOKEN_PROGRAM_ID, false), + ], + ) +} + /// Helper to create an instruction that calls guinea to resize an ephemeral account fn resize_ephemeral_account_ix( magic_program: Pubkey, @@ -142,6 +193,29 @@ fn close_ephemeral_account_ix( ) } +#[tokio::test] +async fn test_create_rent_pending_ata_zero_balance_rolls_back() { + let env = ExecutionTestEnv::new_with_config(0, 1, false); + let payer = env.get_payer().pubkey; + let wallet_owner = Pubkey::new_unique(); + let mint = Pubkey::new_unique(); + let ata = derive_ata(&wallet_owner, &mint); + + init_spl_mint(&env, mint); + env.fund_account(ata, 0); + + let ix = create_rent_pending_ata_ix(payer, wallet_owner, mint); + let err = execute_instruction(&env, ix).await.unwrap_err(); + + assert!(matches!( + err, + solana_transaction_error::TransactionError::UnbalancedTransaction + )); + let ata_after = env.get_account(ata); + assert_eq!(ata_after.owner(), &Pubkey::default()); + assert!(ata_after.data().is_empty()); +} + #[tokio::test] async fn test_create_ephemeral_account_via_cpi() { let env = ExecutionTestEnv::new_with_config(0, 1, false); diff --git a/programs/magicblock/Cargo.toml b/programs/magicblock/Cargo.toml index 1e052070a..8959c5222 100644 --- a/programs/magicblock/Cargo.toml +++ b/programs/magicblock/Cargo.toml @@ -37,6 +37,9 @@ solana-signer = { workspace = true } solana-sysvar = { workspace = true } solana-transaction = { workspace = true } solana-transaction-context = { workspace = true } +solana-program = { workspace = true } +spl-token = { workspace = true } +spl-token-2022 = { workspace = true } thiserror = { workspace = true } [dev-dependencies] diff --git a/programs/magicblock/src/ephemeral_accounts/mod.rs b/programs/magicblock/src/ephemeral_accounts/mod.rs index abe115294..9b2898755 100644 --- a/programs/magicblock/src/ephemeral_accounts/mod.rs +++ b/programs/magicblock/src/ephemeral_accounts/mod.rs @@ -5,12 +5,14 @@ mod process_close; mod process_create; +mod process_create_rent_pending_ata; mod process_resize; mod validation; use magicblock_magic_program_api::EPHEMERAL_RENT_PER_BYTE; pub(crate) use process_close::process_close_ephemeral_account; pub(crate) use process_create::process_create_ephemeral_account; +pub(crate) use process_create_rent_pending_ata::process_create_rent_pending_ata; pub(crate) use process_resize::process_resize_ephemeral_account; use solana_account::{AccountSharedData, ReadableAccount}; use solana_instruction::error::InstructionError; diff --git a/programs/magicblock/src/ephemeral_accounts/process_create_rent_pending_ata.rs b/programs/magicblock/src/ephemeral_accounts/process_create_rent_pending_ata.rs new file mode 100644 index 000000000..bad9cbe2c --- /dev/null +++ b/programs/magicblock/src/ephemeral_accounts/process_create_rent_pending_ata.rs @@ -0,0 +1,231 @@ +use magicblock_core::{ + tls::ExecutionTlsStash, + token_programs::{ + derive_ata_with_token_program, try_get_rent_pending_ata_info, + try_remap_ata_to_eata, RENT_PENDING_ATA_CLOSE_AUTHORITY, + TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID, + }, +}; +use solana_account::{ReadableAccount, WritableAccount}; +use solana_instruction::error::InstructionError; +use solana_log_collector::ic_msg; +use solana_program::{program_option::COption, program_pack::Pack}; +use solana_program_runtime::invoke_context::InvokeContext; +use solana_pubkey::Pubkey; +use solana_sdk_ids::system_program; +use solana_transaction_context::TransactionContext; +use spl_token::state::{ + Account as SplAccount, AccountState as SplAccountState, Mint as SplMint, +}; +use spl_token_2022::{ + extension::{ + BaseStateWithExtensions, StateWithExtensions, StateWithExtensionsMut, + }, + state::{ + Account as Token2022Account, AccountState as Token2022AccountState, + Mint as Token2022Mint, + }, +}; + +use crate::utils::accounts::{ + get_instruction_account_with_idx, get_instruction_pubkey_with_idx, +}; + +const PAYER_IDX: u16 = 0; +const ATA_IDX: u16 = 1; +const MINT_IDX: u16 = 2; +const TOKEN_PROGRAM_IDX: u16 = 3; + +pub(crate) fn process_create_rent_pending_ata( + invoke_context: &InvokeContext, + transaction_context: &TransactionContext, + wallet_owner: Pubkey, + mint: Pubkey, + token_program: Pubkey, +) -> Result<(), InstructionError> { + let ix_ctx = transaction_context.get_current_instruction_context()?; + if !ix_ctx.is_instruction_account_signer(PAYER_IDX)? { + return Err(InstructionError::MissingRequiredSignature); + } + + let ata_pubkey = + *get_instruction_pubkey_with_idx(transaction_context, ATA_IDX)?; + let mint_pubkey = + *get_instruction_pubkey_with_idx(transaction_context, MINT_IDX)?; + let token_program_pubkey = *get_instruction_pubkey_with_idx( + transaction_context, + TOKEN_PROGRAM_IDX, + )?; + + if mint_pubkey != mint || token_program_pubkey != token_program { + return Err(InstructionError::InvalidArgument); + } + if token_program != TOKEN_PROGRAM_ID + && token_program != TOKEN_2022_PROGRAM_ID + { + return Err(InstructionError::UnsupportedProgramId); + } + if is_native_mint(&mint, &token_program) { + return Err(InstructionError::InvalidArgument); + } + let expected_ata = + derive_ata_with_token_program(&wallet_owner, &mint, &token_program); + if ata_pubkey != expected_ata { + return Err(InstructionError::InvalidSeeds); + } + + let ata = get_instruction_account_with_idx(transaction_context, ATA_IDX)?; + let ata_shared = ata.to_account_shared_data()?; + if !is_empty_system_account(&ata_shared) { + if is_matching_existing_rent_pending_ata( + &ata_pubkey, + &ata_shared, + &wallet_owner, + &mint, + &token_program, + ) || is_matching_existing_projected_ata( + &ata_pubkey, + &ata_shared, + &wallet_owner, + &mint, + &token_program, + ) { + return Ok(()); + } + return Err(InstructionError::InvalidAccountData); + } + + let mint_acc = + get_instruction_account_with_idx(transaction_context, MINT_IDX)?; + let mint_data_len = { + let mint_acc = mint_acc.borrow()?; + if mint_acc.owner() != &token_program { + return Err(InstructionError::InvalidAccountOwner); + } + token_account_len(mint_acc.data(), &token_program)? + }; + + let mut acc = ata.borrow_mut()?; + acc.set_lamports(0); + acc.set_owner(token_program); + acc.resize(mint_data_len, 0); + initialize_token_data( + acc.data_as_mut_slice(), + &token_program, + wallet_owner, + mint, + )?; + acc.set_remote_slot(0); + acc.set_delegated(true); + acc.set_ephemeral(false); + acc.set_confined(false); + acc.set_undelegating(false); + ExecutionTlsStash::register_created_rent_pending_ata(ata_pubkey); + + ic_msg!( + invoke_context, + "Created rent-pending ATA {} for owner {} mint {}", + ata_pubkey, + wallet_owner, + mint + ); + Ok(()) +} + +fn is_empty_system_account( + account: &solana_account::AccountSharedData, +) -> bool { + account.lamports() == 0 + && account.owner() == &system_program::ID + && account.data().is_empty() +} + +fn is_native_mint(mint: &Pubkey, token_program: &Pubkey) -> bool { + (*token_program == TOKEN_PROGRAM_ID + && *mint == spl_token::native_mint::id()) + || (*token_program == TOKEN_2022_PROGRAM_ID + && *mint == spl_token_2022::native_mint::id()) +} + +fn token_account_len( + mint_data: &[u8], + token_program: &Pubkey, +) -> Result { + if *token_program == TOKEN_PROGRAM_ID { + SplMint::unpack(mint_data) + .map_err(|_| InstructionError::InvalidAccountData)?; + Ok(SplAccount::LEN) + } else { + let mint = StateWithExtensions::::unpack(mint_data) + .map_err(|_| InstructionError::InvalidAccountData)?; + mint.try_get_account_len() + .map_err(|_| InstructionError::InvalidAccountData) + } +} + +fn initialize_token_data( + data: &mut [u8], + token_program: &Pubkey, + wallet_owner: Pubkey, + mint: Pubkey, +) -> Result<(), InstructionError> { + if *token_program == TOKEN_PROGRAM_ID { + let account = SplAccount { + mint, + owner: wallet_owner, + amount: 0, + delegate: COption::None, + state: SplAccountState::Initialized, + is_native: COption::None, + delegated_amount: 0, + close_authority: COption::Some(RENT_PENDING_ATA_CLOSE_AUTHORITY), + }; + SplAccount::pack(account, data) + .map_err(|_| InstructionError::InvalidAccountData) + } else { + let mut state = + StateWithExtensionsMut::::unpack(data) + .map_err(|_| InstructionError::InvalidAccountData)?; + state.base = Token2022Account { + mint, + owner: wallet_owner, + amount: 0, + delegate: COption::None, + state: Token2022AccountState::Initialized, + is_native: COption::None, + delegated_amount: 0, + close_authority: COption::Some(RENT_PENDING_ATA_CLOSE_AUTHORITY), + }; + state.pack_base(); + Ok(()) + } +} + +fn is_matching_existing_rent_pending_ata( + ata_pubkey: &Pubkey, + account: &solana_account::AccountSharedData, + wallet_owner: &Pubkey, + mint: &Pubkey, + token_program: &Pubkey, +) -> bool { + try_get_rent_pending_ata_info(ata_pubkey, account).is_some_and(|info| { + info.wallet_owner == *wallet_owner + && info.mint == *mint + && info.token_program == *token_program + }) +} + +fn is_matching_existing_projected_ata( + ata_pubkey: &Pubkey, + account: &solana_account::AccountSharedData, + wallet_owner: &Pubkey, + mint: &Pubkey, + token_program: &Pubkey, +) -> bool { + if account.owner() != token_program { + return false; + } + try_remap_ata_to_eata(ata_pubkey, account).is_some_and(|(_, eata)| { + eata.owner == *wallet_owner && eata.mint == *mint + }) +} diff --git a/programs/magicblock/src/magic_scheduled_base_intent.rs b/programs/magicblock/src/magic_scheduled_base_intent.rs index 88050bf62..cc27ba4ae 100644 --- a/programs/magicblock/src/magic_scheduled_base_intent.rs +++ b/programs/magicblock/src/magic_scheduled_base_intent.rs @@ -1,8 +1,12 @@ -use std::collections::{HashMap, HashSet}; +use std::{ + cell::RefCell, + collections::{HashMap, HashSet}, +}; use magicblock_core::{ intent::{BaseActionCallback, CommittedAccount}, token_programs::{ + try_get_rent_pending_ata_info, RentPendingAtaMaterialization, EATA_PROGRAM_ID, TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID, }, Slot, @@ -38,6 +42,9 @@ pub const ACTUAL_COMMIT_LIMIT: u64 = 25; /// Fixed fee per commit. /// https://github.com/magicblock-labs/delegation-program/blob/main/src/consts.rs#L11 pub const COMMIT_FEE_LAMPORTS: u64 = 100_000; +/// Fixed V1 fee per rent-pending eATA materialization. +pub const RENT_PENDING_ATA_MATERIALIZATION_FEE_LAMPORTS: u64 = + COMMIT_FEE_LAMPORTS; /// Price per compute unit for a BaseAction executed on Solana base chain, /// denominated in micro-lamports per CU (mirrors Solana's priority fee model). pub const COMPUTE_UNIT_PRICE_MICRO_LAMPORTS: u64 = 50_000; @@ -51,6 +58,8 @@ pub struct ConstructionContext<'a, 'ic, 'ix_data> { /// to the delegation program. Legacy `ScheduleBaseIntent` sets this to /// `false` for backward compatibility with old deployed contracts. pub secure: bool, + pub rent_pending_materialization_accounts: Option<(Pubkey, Pubkey)>, + rent_pending_materializations: RefCell>, } impl<'a, 'ic, 'ix_data> ConstructionContext<'a, 'ic, 'ix_data> { @@ -59,18 +68,58 @@ impl<'a, 'ic, 'ix_data> ConstructionContext<'a, 'ic, 'ix_data> { signers: &'a HashSet, invoke_context: &'a mut InvokeContext<'ic, 'ix_data>, secure: bool, + rent_pending_materialization_accounts: Option<(Pubkey, Pubkey)>, ) -> Self { Self { parent_program_id, signers, invoke_context, secure, + rent_pending_materialization_accounts, + rent_pending_materializations: RefCell::new(Vec::new()), } } pub fn transaction_context(&self) -> &TransactionContext<'ix_data> { &*self.invoke_context.transaction_context } + + fn record_rent_pending_materializations( + &self, + accounts: &[CommitAccountRef<'_, '_>], + ) -> Result<(), InstructionError> { + let mut materializations = + self.rent_pending_materializations.borrow_mut(); + for (pubkey, account) in accounts { + let account = account.to_account_shared_data()?; + let Some(info) = try_get_rent_pending_ata_info(pubkey, &account) + else { + continue; + }; + let Some((delegated_payer, delegated_vault)) = + self.rent_pending_materialization_accounts + else { + ic_msg!( + self.invoke_context, + "ScheduleCommit ERR: rent-pending ATA {} requires delegated payer fee vault", + pubkey + ); + return Err(InstructionError::IllegalOwner); + }; + materializations.push(RentPendingAtaMaterialization { + ata_pubkey: info.ata_pubkey, + eata_pubkey: info.eata_pubkey, + token_program: info.token_program, + wallet_owner: info.wallet_owner, + mint: info.mint, + token_account_data_len: info.token_account_data_len, + validator: effective_validator_authority_id(), + delegated_payer, + delegated_vault, + }); + } + Ok(()) + } } type CommitAccountRef<'a, 'ix_data> = @@ -218,6 +267,7 @@ pub struct MagicIntentBundle { pub commit_finalize: Option, pub commit_finalize_and_undelegate: Option, pub standalone_actions: Vec, + pub rent_pending_ata_materializations: Vec, } impl From for MagicIntentBundle { @@ -282,6 +332,10 @@ impl MagicIntentBundle { commit_finalize, commit_finalize_and_undelegate, standalone_actions: actions, + rent_pending_ata_materializations: context + .rent_pending_materializations + .borrow() + .clone(), }; this.post_validation(context)?; @@ -989,6 +1043,9 @@ impl CommitType { context.transaction_context(), )?; Self::validate_accounts(&committed_accounts_ref, context)?; + context.record_rent_pending_materializations( + &committed_accounts_ref, + )?; let committed_accounts = committed_accounts_ref .into_iter() .map(|(pubkey, account)| { @@ -1012,6 +1069,9 @@ impl CommitType { context.transaction_context(), )?; Self::validate_accounts(&committed_accounts_ref, context)?; + context.record_rent_pending_materializations( + &committed_accounts_ref, + )?; let base_actions = base_actions .into_iter() @@ -1243,6 +1303,14 @@ pub(crate) fn calculate_commit_fee( }) } +pub(crate) fn calculate_rent_pending_ata_materialization_fee( + count: usize, +) -> Result { + (count as u64) + .checked_mul(RENT_PENDING_ATA_MATERIALIZATION_FEE_LAMPORTS) + .ok_or(InstructionError::ArithmeticOverflow) +} + fn calculate_actions_fee(actions: &[BaseAction]) -> u64 { const MICRO_LAMPORTS_PER_LAMPORT: u64 = 1_000_000; let micro_lamports = actions.iter().fold(0u64, |acc, action| { @@ -1397,6 +1465,7 @@ mod tests { commit_finalize: None, commit_finalize_and_undelegate: None, standalone_actions: vec![make_base_action(50_000)], + rent_pending_ata_materializations: vec![], }, }; diff --git a/programs/magicblock/src/magicblock_processor.rs b/programs/magicblock/src/magicblock_processor.rs index d39ed2486..cb0070db1 100644 --- a/programs/magicblock/src/magicblock_processor.rs +++ b/programs/magicblock/src/magicblock_processor.rs @@ -17,7 +17,7 @@ use crate::{ }, ephemeral_accounts::{ process_close_ephemeral_account, process_create_ephemeral_account, - process_resize_ephemeral_account, + process_create_rent_pending_ata, process_resize_ephemeral_account, }, mutate_accounts::process_mutate_accounts, process_scheduled_commit_sent, @@ -86,13 +86,17 @@ declare_process_instruction!( request_undelegation: true, }, ), - Unused => { - solana_log_collector::ic_msg!( - invoke_context, - "MagicBlockInstruction ERR: Unused instruction slot" - ); - Err(InstructionError::InvalidInstructionData) - } + CreateRentPendingAta { + wallet_owner, + mint, + token_program, + } => process_create_rent_pending_ata( + invoke_context, + transaction_context, + wallet_owner, + mint, + token_program, + ), AcceptScheduleCommits => { process_accept_scheduled_commits(signers, invoke_context) } diff --git a/programs/magicblock/src/schedule_transactions/process_schedule_commit.rs b/programs/magicblock/src/schedule_transactions/process_schedule_commit.rs index a4a0f641e..b26f5f57e 100644 --- a/programs/magicblock/src/schedule_transactions/process_schedule_commit.rs +++ b/programs/magicblock/src/schedule_transactions/process_schedule_commit.rs @@ -1,6 +1,11 @@ use std::collections::HashSet; -use magicblock_core::intent::CommittedAccount; +use magicblock_core::{ + intent::CommittedAccount, + token_programs::{ + try_get_rent_pending_ata_info, RentPendingAtaMaterialization, + }, +}; // no direct token remap helpers needed here; handled in CommittedAccount builder use solana_account::{ReadableAccount, WritableAccount}; use solana_instruction::error::InstructionError; @@ -10,12 +15,15 @@ use solana_pubkey::Pubkey; use crate::{ magic_scheduled_base_intent::{ - calculate_commit_fee, validate_commit_schedule_permissions, - CommitAndUndelegate, CommitType, MagicBaseIntent, - ScheduledIntentBundle, UndelegateType, + calculate_commit_fee, calculate_rent_pending_ata_materialization_fee, + validate_commit_schedule_permissions, CommitAndUndelegate, CommitType, + MagicBaseIntent, MagicIntentBundle, ScheduledIntentBundle, + UndelegateType, }, magic_sys::fetch_current_commit_nonces, - schedule_transactions::{self, check_commit_limits, try_get_fee_vault}, + schedule_transactions::{ + self, check_commit_limits, magic_fee_vault_pubkey, try_get_fee_vault, + }, utils::{ account_actions::{ charge_delegated_payer, mark_account_as_undelegated, @@ -26,6 +34,7 @@ use crate::{ }, instruction_utils::InstructionUtils, }, + validator::effective_validator_authority_id, MagicContext, }; @@ -142,6 +151,7 @@ pub(crate) fn process_schedule_commit( // program owning the PDAs invoked us directly via CPI is sufficient // Thus we can be `invoke`d unsigned and no seeds need to be provided let mut committed_accounts: Vec = Vec::new(); + let mut rent_pending_ata_materializations = Vec::new(); let mut seen_committed_pubkeys: HashSet = HashSet::new(); for idx in committees_start..ix_accs_len { let acc_pubkey = @@ -203,6 +213,16 @@ pub(crate) fn process_schedule_commit( )?; let account = acc.to_account_shared_data()?; + let rent_pending_info = + try_get_rent_pending_ata_info(acc_pubkey, &account); + if rent_pending_info.is_some() && magic_fee_vault.is_none() { + ic_msg!( + invoke_context, + "ScheduleCommit ERR: rent-pending ATA {} requires delegated payer fee vault", + acc_pubkey + ); + return Err(InstructionError::IllegalOwner); + } let committed = CommittedAccount::from_account_shared( *acc_pubkey, &account, @@ -224,6 +244,21 @@ pub(crate) fn process_schedule_commit( continue; } + if let Some(info) = rent_pending_info { + rent_pending_ata_materializations.push( + RentPendingAtaMaterialization { + ata_pubkey: info.ata_pubkey, + eata_pubkey: info.eata_pubkey, + token_program: info.token_program, + wallet_owner: info.wallet_owner, + mint: info.mint, + token_account_data_len: info.token_account_data_len, + validator: effective_validator_authority_id(), + delegated_payer: *payer_pubkey, + delegated_vault: magic_fee_vault_pubkey(), + }, + ); + } committed_accounts.push(committed); } @@ -251,8 +286,24 @@ pub(crate) fn process_schedule_commit( } if let Some(fee_vault) = magic_fee_vault { - let nonces = fetch_current_commit_nonces(&committed_accounts)?; - let fee = calculate_commit_fee(&committed_accounts, &nonces)?; + let rent_pending_pubkeys = rent_pending_ata_materializations + .iter() + .map(|materialization| materialization.eata_pubkey) + .collect::>(); + let nonce_accounts = committed_accounts + .iter() + .filter(|account| !rent_pending_pubkeys.contains(&account.pubkey)) + .cloned() + .collect::>(); + let mut nonces = fetch_current_commit_nonces(&nonce_accounts)?; + for pubkey in rent_pending_pubkeys { + nonces.insert(pubkey, 0); + } + let fee = calculate_commit_fee(&committed_accounts, &nonces)? + .checked_add(calculate_rent_pending_ata_materialization_fee( + rent_pending_ata_materializations.len(), + )?) + .ok_or(InstructionError::ArithmeticOverflow)?; charge_delegated_payer(&payer_account, &fee_vault, fee)?; } else if !opts.request_undelegation { // We validate commit nonces only for plain commits. @@ -295,7 +346,7 @@ pub(crate) fn process_schedule_commit( InstructionUtils::scheduled_commit_sent(intent_id, blockhash); let commit_sent_sig = action_sent_transaction.signatures[0]; - let base_intent = if opts.request_undelegation { + let mut base_intent: MagicIntentBundle = if opts.request_undelegation { MagicBaseIntent::CommitFinalizeAndUndelegate(CommitAndUndelegate { commit_action: CommitType::Standalone(committed_accounts), undelegate_action: UndelegateType::Standalone, @@ -306,6 +357,8 @@ pub(crate) fn process_schedule_commit( )) } .into(); + base_intent.rent_pending_ata_materializations = + rent_pending_ata_materializations; let scheduled_base_intent = ScheduledIntentBundle { id: intent_id, diff --git a/programs/magicblock/src/schedule_transactions/process_schedule_commit_tests.rs b/programs/magicblock/src/schedule_transactions/process_schedule_commit_tests.rs index eeb724135..67abd84ee 100644 --- a/programs/magicblock/src/schedule_transactions/process_schedule_commit_tests.rs +++ b/programs/magicblock/src/schedule_transactions/process_schedule_commit_tests.rs @@ -23,6 +23,7 @@ use crate::{ magic_context::MagicContext, magic_scheduled_base_intent::{ ScheduledIntentBundle, ACTUAL_COMMIT_LIMIT, COMMIT_FEE_LAMPORTS, + RENT_PENDING_ATA_MATERIALIZATION_FEE_LAMPORTS, }, magic_sys::COMMIT_LIMIT, schedule_transactions::{ @@ -281,10 +282,14 @@ mod tests { }; use magicblock_core::token_programs::{ derive_ata, derive_ata_with_token_program, derive_eata, - EATA_PROGRAM_ID, TOKEN_2022_PROGRAM_ID, + EATA_PROGRAM_ID, RENT_PENDING_ATA_CLOSE_AUTHORITY, + TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID, }; use serial_test::serial; + use solana_account::WritableAccount; + use solana_program::{program_option::COption, program_pack::Pack}; use solana_seed_derivable::SeedDerivable; + use spl_token::state::Account as SplAccount; use test_kit::init_logger; use super::*; @@ -310,6 +315,20 @@ mod tests { acc } + fn make_rent_pending_spl_ata_account( + owner: &Pubkey, + mint: &Pubkey, + amount: u64, + ) -> AccountSharedData { + let mut acc = AccountSharedData::from(create_ata_account(owner, mint)); + let mut token = SplAccount::unpack(acc.data()).unwrap(); + token.amount = amount; + token.close_authority = COption::Some(RENT_PENDING_ATA_CLOSE_AUTHORITY); + SplAccount::pack(token, acc.data_as_mut_slice()).unwrap(); + acc.set_delegated(true); + acc + } + #[test] #[serial] fn test_schedule_commit_single_account_success() { @@ -1582,6 +1601,87 @@ mod tests { .expect("payer should have been debited"); } + #[test] + #[serial] + fn test_schedule_commit_rent_pending_ata_records_materialization() { + init_logger!(); + let payer = + Keypair::from_seed(b"rent_pending_ata_materialization__").unwrap(); + let wallet_owner = Pubkey::new_unique(); + let mint = Pubkey::new_unique(); + let ata = derive_ata(&wallet_owner, &mint); + let eata = derive_eata(&wallet_owner, &mint); + + let (mut account_data, mut transaction_accounts) = + prepare_delegated_payer_transaction( + &payer, + TOKEN_PROGRAM_ID, + &[ata], + StubNonces::Global(0), + ); + account_data.insert( + ata, + make_rent_pending_spl_ata_account(&wallet_owner, &mint, 42), + ); + + let ix = + InstructionUtils::schedule_commit_with_delegated_payer_instruction( + &payer.pubkey(), + vec![ata, ata], + ); + extend_transaction_accounts_from_ix( + &ix, + &mut account_data, + &mut transaction_accounts, + ); + + let processed_scheduled = process_instruction( + ix.data.as_slice(), + transaction_accounts, + ix.accounts, + Ok(()), + ); + + processed_scheduled + .iter() + .find(|a| { + a.lamports() == RENT_PENDING_ATA_MATERIALIZATION_FEE_LAMPORTS + && a.delegated() + }) + .expect( + "fee vault should receive rent-pending materialization fee", + ); + processed_scheduled + .iter() + .find(|a| { + a.lamports() + == 1_000_000 - RENT_PENDING_ATA_MATERIALIZATION_FEE_LAMPORTS + && a.delegated() + }) + .expect("payer should be charged rent-pending materialization fee"); + + let magic_context_acc = assert_non_accepted_actions( + &processed_scheduled, + &payer.pubkey(), + 1, + ); + let magic_context = + bincode::deserialize::(magic_context_acc.data()) + .unwrap(); + let intent = &magic_context.scheduled_base_intents[0].intent_bundle; + + assert_eq!(intent.get_all_committed_pubkeys(), vec![eata]); + assert_eq!(intent.rent_pending_ata_materializations.len(), 1); + let materialization = &intent.rent_pending_ata_materializations[0]; + assert_eq!(materialization.ata_pubkey, ata); + assert_eq!(materialization.eata_pubkey, eata); + assert_eq!(materialization.wallet_owner, wallet_owner); + assert_eq!(materialization.mint, mint); + assert_eq!(materialization.token_program, TOKEN_PROGRAM_ID); + assert_eq!(materialization.delegated_payer, payer.pubkey()); + assert_eq!(materialization.delegated_vault, magic_fee_vault_pubkey()); + } + #[test] #[serial] fn test_schedule_commit_delegated_payer_only_charges_above_limit() { diff --git a/programs/magicblock/src/schedule_transactions/process_schedule_intent_bundle.rs b/programs/magicblock/src/schedule_transactions/process_schedule_intent_bundle.rs index 5210e36f6..e60338b69 100644 --- a/programs/magicblock/src/schedule_transactions/process_schedule_intent_bundle.rs +++ b/programs/magicblock/src/schedule_transactions/process_schedule_intent_bundle.rs @@ -9,7 +9,8 @@ use solana_pubkey::Pubkey; use crate::{ magic_scheduled_base_intent::{ - CommitType, ConstructionContext, ScheduledIntentBundle, + calculate_rent_pending_ata_materialization_fee, CommitType, + ConstructionContext, ScheduledIntentBundle, }, magic_sys::fetch_current_commit_nonces, schedule_transactions::{ @@ -81,6 +82,27 @@ pub(crate) fn process_schedule_intent_bundle( // Get next intent id let intent_id = context.next_intent_id(); + let rent_pending_materialization_accounts = { + let transaction_context = &*invoke_context.transaction_context; + let fee_vault = try_get_fee_vault( + transaction_context, + invoke_context, + PAYER_IDX, + MAGIC_CONTEXT_IDX + 1, + )?; + if fee_vault.is_some() { + Some(( + payer_pubkey, + *get_instruction_pubkey_with_idx( + transaction_context, + MAGIC_CONTEXT_IDX + 1, + )?, + )) + } else { + None + } + }; + // Determine id and slot let (undelegated_pubkeys, scheduled_intent) = { let construction_context = ConstructionContext::new( @@ -88,6 +110,7 @@ pub(crate) fn process_schedule_intent_bundle( &signers, invoke_context, secure, + rent_pending_materialization_accounts, ); // Collect all undelegated account refs. @@ -143,9 +166,30 @@ pub(crate) fn process_schedule_intent_bundle( MAGIC_CONTEXT_IDX + 1, )?; if let Some(magic_fee_vault) = magic_fee_vault { - let chargable_accounts = scheduled_intent.get_all_committed_accounts(); - let nonces = fetch_current_commit_nonces(&chargable_accounts)?; - let fee = scheduled_intent.calculate_fee(&nonces)?; + let rent_pending_pubkeys = scheduled_intent + .intent_bundle + .rent_pending_ata_materializations + .iter() + .map(|materialization| materialization.eata_pubkey) + .collect::>(); + let chargable_accounts = scheduled_intent + .get_all_committed_accounts() + .into_iter() + .filter(|account| !rent_pending_pubkeys.contains(&account.pubkey)) + .collect::>(); + let mut nonces = fetch_current_commit_nonces(&chargable_accounts)?; + for pubkey in rent_pending_pubkeys { + nonces.insert(pubkey, 0); + } + let fee = scheduled_intent + .calculate_fee(&nonces)? + .checked_add(calculate_rent_pending_ata_materialization_fee( + scheduled_intent + .intent_bundle + .rent_pending_ata_materializations + .len(), + )?) + .ok_or(InstructionError::ArithmeticOverflow)?; charge_delegated_payer(&payer_account, &magic_fee_vault, fee)?; } else if let Some(commit_accounts) = scheduled_intent.get_commit_intent_accounts() diff --git a/test-integration/Cargo.lock b/test-integration/Cargo.lock index 04305a12b..42862eaf1 100644 --- a/test-integration/Cargo.lock +++ b/test-integration/Cargo.lock @@ -4051,6 +4051,7 @@ dependencies = [ "solana-keypair", "solana-loader-v3-interface 6.1.1", "solana-loader-v4-interface 3.1.0", + "solana-program 3.0.0", "solana-program-runtime", "solana-pubkey 4.1.0", "solana-sdk-ids 3.1.0", @@ -4061,6 +4062,8 @@ dependencies = [ "solana-sysvar 3.1.1", "solana-transaction", "solana-transaction-context", + "spl-token-2022-interface", + "spl-token-interface", "thiserror 2.0.18", ] From 3f2fe414d7c1d7890226f6b612c15a0c42fed543 Mon Sep 17 00:00:00 2001 From: Gabriele Picco Date: Wed, 1 Jul 2026 18:29:14 +0200 Subject: [PATCH 02/16] Fix rent-pending ATA CI coverage --- Cargo.lock | 1 + magicblock-committor-service/Cargo.toml | 1 + magicblock-committor-service/src/tasks/mod.rs | 5 +- .../process_create_rent_pending_ata.rs | 90 +++++++++++++++++++ test-integration/Cargo.lock | 1 + 5 files changed, 96 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 64cd5aab6..6e4d36263 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3729,6 +3729,7 @@ dependencies = [ "solana-pubkey 4.1.0", "solana-rpc-client", "solana-rpc-client-api", + "solana-sdk-ids 3.1.0", "solana-signature 3.3.0", "solana-signer", "solana-transaction", diff --git a/magicblock-committor-service/Cargo.toml b/magicblock-committor-service/Cargo.toml index d9728d739..826accfe0 100644 --- a/magicblock-committor-service/Cargo.toml +++ b/magicblock-committor-service/Cargo.toml @@ -46,6 +46,7 @@ solana-program = { workspace = true } solana-pubkey = { workspace = true } solana-rpc-client = { workspace = true } solana-rpc-client-api = { workspace = true } +solana-sdk-ids = { workspace = true } solana-signature = { workspace = true } solana-signer = { workspace = true } solana-transaction = { workspace = true } diff --git a/magicblock-committor-service/src/tasks/mod.rs b/magicblock-committor-service/src/tasks/mod.rs index 9c8b81108..7046e9830 100644 --- a/magicblock-committor-service/src/tasks/mod.rs +++ b/magicblock-committor-service/src/tasks/mod.rs @@ -26,6 +26,7 @@ use magicblock_metrics::metrics::LabelValue; use magicblock_program::magic_scheduled_base_intent::BaseAction; use solana_instruction::{AccountMeta, Instruction}; use solana_pubkey::Pubkey; +use solana_sdk_ids::system_program; pub mod commit_finalize_task; pub mod commit_task; @@ -200,7 +201,7 @@ impl RentPendingAtaTask { AccountMeta::new(*validator, true), AccountMeta::new_readonly(materialization.wallet_owner, false), AccountMeta::new_readonly(materialization.mint, false), - AccountMeta::new_readonly(solana_system_program::id(), false), + AccountMeta::new_readonly(system_program::id(), false), ], data: vec![Self::INITIALIZE_EPHEMERAL_ATA_IX], } @@ -235,7 +236,7 @@ impl RentPendingAtaTask { AccountMeta::new(delegation_record_pda, false), AccountMeta::new(delegation_metadata_pda, false), AccountMeta::new_readonly(dlp_api::id(), false), - AccountMeta::new_readonly(solana_system_program::id(), false), + AccountMeta::new_readonly(system_program::id(), false), ], data, } diff --git a/programs/magicblock/src/ephemeral_accounts/process_create_rent_pending_ata.rs b/programs/magicblock/src/ephemeral_accounts/process_create_rent_pending_ata.rs index bad9cbe2c..4f925b336 100644 --- a/programs/magicblock/src/ephemeral_accounts/process_create_rent_pending_ata.rs +++ b/programs/magicblock/src/ephemeral_accounts/process_create_rent_pending_ata.rs @@ -229,3 +229,93 @@ fn is_matching_existing_projected_ata( eata.owner == *wallet_owner && eata.mint == *mint }) } + +#[cfg(test)] +mod tests { + use magicblock_magic_program_api::instruction::MagicBlockInstruction; + use solana_account::{AccountSharedData, ReadableAccount, WritableAccount}; + use solana_instruction::{AccountMeta, Instruction}; + use solana_sdk_ids::{native_loader, system_program}; + + use super::*; + use crate::test_utils::process_instruction; + + fn spl_mint_account() -> AccountSharedData { + let mint_state = SplMint { + mint_authority: COption::None, + supply: 0, + decimals: 6, + is_initialized: true, + freeze_authority: COption::None, + }; + let mut account = + AccountSharedData::new(1_000_000, SplMint::LEN, &TOKEN_PROGRAM_ID); + SplMint::pack(mint_state, account.data_as_mut_slice()).unwrap(); + account + } + + #[test] + fn create_rent_pending_ata_initializes_local_token_account_shape() { + let payer = Pubkey::new_unique(); + let wallet_owner = Pubkey::new_unique(); + let mint = Pubkey::new_unique(); + let ata = derive_ata_with_token_program( + &wallet_owner, + &mint, + &TOKEN_PROGRAM_ID, + ); + + let ix = Instruction::new_with_bincode( + crate::id(), + &MagicBlockInstruction::CreateRentPendingAta { + wallet_owner, + mint, + token_program: TOKEN_PROGRAM_ID, + }, + vec![ + AccountMeta::new(payer, true), + AccountMeta::new(ata, false), + AccountMeta::new_readonly(mint, false), + AccountMeta::new_readonly(TOKEN_PROGRAM_ID, false), + ], + ); + let accounts = process_instruction( + &ix.data, + vec![ + ( + payer, + AccountSharedData::new(1_000_000, 0, &system_program::id()), + ), + (ata, AccountSharedData::new(0, 0, &system_program::id())), + (mint, spl_mint_account()), + ( + TOKEN_PROGRAM_ID, + AccountSharedData::new(1, 0, &native_loader::id()), + ), + ], + ix.accounts, + Ok(()), + ); + + let ata_after = &accounts[1]; + assert_eq!(ata_after.owner(), &TOKEN_PROGRAM_ID); + assert!(ata_after.delegated()); + assert!(!ata_after.ephemeral()); + assert!(!ata_after.confined()); + assert!(!ata_after.undelegating()); + assert_eq!(ata_after.remote_slot(), 0); + + let token_account = SplAccount::unpack(ata_after.data()).unwrap(); + assert_eq!(token_account.mint, mint); + assert_eq!(token_account.owner, wallet_owner); + assert_eq!(token_account.amount, 0); + assert_eq!(token_account.delegate, COption::None); + assert_eq!(token_account.state, SplAccountState::Initialized); + assert_eq!(token_account.is_native, COption::None); + assert_eq!(token_account.delegated_amount, 0); + assert_eq!( + token_account.close_authority, + COption::Some(RENT_PENDING_ATA_CLOSE_AUTHORITY) + ); + } +} diff --git a/test-integration/Cargo.lock b/test-integration/Cargo.lock index 42862eaf1..3c8bccd50 100644 --- a/test-integration/Cargo.lock +++ b/test-integration/Cargo.lock @@ -3784,6 +3784,7 @@ dependencies = [ "solana-pubkey 4.1.0", "solana-rpc-client", "solana-rpc-client-api", + "solana-sdk-ids 3.1.0", "solana-signature", "solana-signer", "solana-transaction", From 931f9e0144cabc6e08425f3f676f569bce76c9b5 Mon Sep 17 00:00:00 2001 From: Gabriele Picco Date: Fri, 3 Jul 2026 18:14:02 +0800 Subject: [PATCH 03/16] fix: rent-pending ATA committor, Token-2022 init, and context compat - Fetch real commit nonces for rent-pending eATAs, defaulting missing delegation metadata to nonce 0 only for allowed pubkeys, so repeat commits use the on-chain nonce instead of a hardcoded 1 - Default rent reimbursement to the materialization validator for rent-pending eATAs so commit-and-undelegate no longer aborts on the not-yet-created delegation-metadata PDA - Initialize Token-2022 rent-pending ATAs via unpack_uninitialized with required account extensions and account-type byte - Add legacy bincode fallback for MagicContext so pending intents persisted by the previous binary still deserialize --- .../src/intent_executor/task_info_fetcher.rs | 243 +++++++++++++++++- .../src/tasks/task_builder.rs | 206 ++++++++++++--- .../src/tasks/task_strategist.rs | 9 + .../process_create_rent_pending_ata.rs | 227 +++++++++++++++- programs/magicblock/src/magic_context.rs | 194 +++++++++++++- .../test-committor-service/tests/common.rs | 9 + 6 files changed, 833 insertions(+), 55 deletions(-) diff --git a/magicblock-committor-service/src/intent_executor/task_info_fetcher.rs b/magicblock-committor-service/src/intent_executor/task_info_fetcher.rs index b4f26fe1d..9305c6e88 100644 --- a/magicblock-committor-service/src/intent_executor/task_info_fetcher.rs +++ b/magicblock-committor-service/src/intent_executor/task_info_fetcher.rs @@ -1,5 +1,5 @@ use std::{ - collections::HashMap, + collections::{HashMap, HashSet}, mem, num::NonZeroUsize, sync::{Arc, Mutex}, @@ -38,6 +38,15 @@ pub trait TaskInfoFetcher: Send + Sync + 'static { min_context_slot: u64, ) -> TaskInfoFetcherResult>; + /// Fetches next commit nonces, using nonce 1 when allowed metadata is + /// missing on base. Non-allowed missing metadata remains an error. + async fn fetch_next_commit_nonces_with_missing_as_zero( + &self, + pubkeys: &[Pubkey], + min_context_slot: u64, + missing_metadata_as_zero: &[Pubkey], + ) -> TaskInfoFetcherResult>; + /// Fetches current commit nonces for pubkeys /// Missing nonces will be fetched from chain async fn fetch_current_commit_nonces( @@ -118,6 +127,67 @@ impl RpcTaskInfoFetcher { .collect() } + async fn fetch_current_commit_nonces_with_missing_as_zero( + rpc_client: &MagicblockRpcClient, + pubkeys: &[Pubkey], + min_context_slot: u64, + max_retries: NonZeroUsize, + missing_metadata_as_zero: &[Pubkey], + ) -> TaskInfoFetcherResult> { + if pubkeys.is_empty() { + return Ok(HashMap::new()); + } + + let zero_defaults: HashSet<_> = + missing_metadata_as_zero.iter().copied().collect(); + let pda_accounts: Vec = pubkeys + .iter() + .map(|delegated_account| { + Pubkey::find_program_address( + delegation_metadata_seeds_from_delegated_account!( + delegated_account + ), + &dlp_api::id(), + ) + .0 + }) + .collect(); + + let accounts = Self::fetch_optional_accounts_with_retries( + rpc_client, + &pda_accounts, + min_context_slot, + max_retries, + ) + .await?; + + let mut nonces = HashMap::with_capacity(pubkeys.len()); + for ((delegated_pubkey, pda), account) in + pubkeys.iter().copied().zip(pda_accounts).zip(accounts) + { + let nonce = match account { + Some(account) => { + DelegationMetadata::try_from_bytes_with_discriminator( + &account.data, + ) + .map_err(|_| { + TaskInfoFetcherError::InvalidAccountDataError(pda) + })? + .last_update_nonce + } + None if zero_defaults.contains(&delegated_pubkey) => 0, + None => { + return Err(TaskInfoFetcherError::AccountNotFoundError( + pda, + )); + } + }; + nonces.insert(delegated_pubkey, nonce); + } + + Ok(nonces) + } + /// Fetches [`Account`]s with some num of retries pub async fn fetch_accounts_with_retries( rpc_client: &MagicblockRpcClient, @@ -172,6 +242,59 @@ impl RpcTaskInfoFetcher { } } + pub async fn fetch_optional_accounts_with_retries( + rpc_client: &MagicblockRpcClient, + pubkeys: &[Pubkey], + min_context_slot: u64, + max_retries: NonZeroUsize, + ) -> TaskInfoFetcherResult>> { + if pubkeys.is_empty() { + return Ok(Vec::new()); + } + + let mut i = 0; + loop { + i += 1; + let err = match Self::fetch_optional_accounts( + rpc_client, + pubkeys, + min_context_slot, + ) + .await + { + Ok(value) => break Ok(value), + Err(err) => err, + }; + + match err { + err @ TaskInfoFetcherError::InvalidAccountDataError(_) => { + error!(error = ?err, "Unexpected error"); + break Err(err); + } + TaskInfoFetcherError::AccountNotFoundError(_) => { + break Err(err) + } + TaskInfoFetcherError::MinContextSlotNotReachedError(_, _) => { + info!( + min_context_slot, + attempt = i, + "Min context slot not reached" + ); + tokio::time::sleep(Duration::from_millis(100)).await; + } + TaskInfoFetcherError::MagicBlockRpcClientError(ref err) => { + warn!(error = ?err, attempt = i, "Fetch account error"); + } + } + + if i >= max_retries.get() { + break Err(err); + } + + tokio::time::sleep(Duration::from_millis(50)).await; + } + } + /// Fetches specified list of accounts pub async fn fetch_accounts( rpc_client: &MagicblockRpcClient, @@ -221,6 +344,47 @@ impl RpcTaskInfoFetcher { Ok(accounts) } + + pub async fn fetch_optional_accounts( + rpc_client: &MagicblockRpcClient, + pubkeys: &[Pubkey], + min_context_slot: u64, + ) -> TaskInfoFetcherResult>> { + if pubkeys.is_empty() { + return Ok(Vec::new()); + } + + metrics::inc_task_info_fetcher_a_count(); + let commitment = rpc_client.commitment(); + let mut accounts = rpc_client + .get_multiple_accounts_with_config( + pubkeys, + RpcAccountInfoConfig { + encoding: Some(UiAccountEncoding::Base64Zstd), + commitment: Some(commitment), + data_slice: None, + min_context_slot: Some(min_context_slot), + }, + None, + ) + .await + .map_err(|err| { + TaskInfoFetcherError::map_client_error(min_context_slot, err) + })?; + + pubkeys + .iter() + .enumerate() + .map(|(i, pubkey)| { + let Some(account) = accounts.get_mut(i) else { + return Err(TaskInfoFetcherError::AccountNotFoundError( + *pubkey, + )); + }; + Ok(account.take()) + }) + .collect() + } } #[async_trait] @@ -229,20 +393,37 @@ impl TaskInfoFetcher for RpcTaskInfoFetcher { &self, pubkeys: &[Pubkey], min_context_slot: u64, + ) -> TaskInfoFetcherResult> { + self.fetch_next_commit_nonces_with_missing_as_zero( + pubkeys, + min_context_slot, + &[], + ) + .await + } + + async fn fetch_next_commit_nonces_with_missing_as_zero( + &self, + pubkeys: &[Pubkey], + min_context_slot: u64, + missing_metadata_as_zero: &[Pubkey], ) -> TaskInfoFetcherResult> { if pubkeys.is_empty() { return Ok(HashMap::new()); } - let nonces = Self::fetch_metadata_with_retries( + + let nonces = Self::fetch_current_commit_nonces_with_missing_as_zero( &self.rpc_client, pubkeys, min_context_slot, NUM_FETCH_RETRIES, + missing_metadata_as_zero, ) - .await? - .into_iter() - .map(|m| m.last_update_nonce + 1); - Ok(pubkeys.iter().copied().zip(nonces).collect()) + .await?; + Ok(nonces + .into_iter() + .map(|(pubkey, nonce)| (pubkey, nonce + 1)) + .collect()) } async fn fetch_current_commit_nonces( @@ -501,6 +682,20 @@ impl TaskInfoFetcher for CacheTaskInfoFetcher { &self, pubkeys: &[Pubkey], min_context_slot: u64, + ) -> TaskInfoFetcherResult> { + self.fetch_next_commit_nonces_with_missing_as_zero( + pubkeys, + min_context_slot, + &[], + ) + .await + } + + async fn fetch_next_commit_nonces_with_missing_as_zero( + &self, + pubkeys: &[Pubkey], + min_context_slot: u64, + missing_metadata_as_zero: &[Pubkey], ) -> TaskInfoFetcherResult> { if pubkeys.is_empty() { return Ok(HashMap::new()); @@ -537,7 +732,11 @@ impl TaskInfoFetcher for CacheTaskInfoFetcher { let missing_pubkeys: Vec<_> = missing.iter().map(|(pubkey, _)| **pubkey).collect(); self.inner - .fetch_current_commit_nonces(&missing_pubkeys, min_context_slot) + .fetch_next_commit_nonces_with_missing_as_zero( + &missing_pubkeys, + min_context_slot, + missing_metadata_as_zero, + ) .await? }; @@ -549,7 +748,7 @@ impl TaskInfoFetcher for CacheTaskInfoFetcher { } for (pubkey, mut guard) in missing { if let Some(&nonce) = fetched_nonces.get(pubkey) { - *guard = nonce + 1; + *guard = nonce; result.insert(*pubkey, *guard); Ok(()) } else { @@ -984,8 +1183,32 @@ mod tests { pubkeys: &[Pubkey], min_context_slot: u64, ) -> TaskInfoFetcherResult> { - self.fetch_current_commit_nonces(pubkeys, min_context_slot) - .await + self.fetch_next_commit_nonces_with_missing_as_zero( + pubkeys, + min_context_slot, + &[], + ) + .await + } + + async fn fetch_next_commit_nonces_with_missing_as_zero( + &self, + pubkeys: &[Pubkey], + _: u64, + _: &[Pubkey], + ) -> TaskInfoFetcherResult> { + if let Some(delay) = self.delay { + tokio::time::sleep(delay).await; + } + let mut q = self.nonces.lock().unwrap(); + Ok(pubkeys + .iter() + .map(|pk| { + let nonce = + q.pop_front().expect("mock nonce queue exhausted"); + (*pk, nonce + 1) + }) + .collect()) } async fn fetch_current_commit_nonces( diff --git a/magicblock-committor-service/src/tasks/task_builder.rs b/magicblock-committor-service/src/tasks/task_builder.rs index 26c6a48a3..6fff689d3 100644 --- a/magicblock-committor-service/src/tasks/task_builder.rs +++ b/magicblock-committor-service/src/tasks/task_builder.rs @@ -108,6 +108,7 @@ impl TaskBuilderImpl { task_info_fetcher: &Arc, accounts: &[CommittedAccount], min_context_slot: u64, + missing_metadata_as_zero: &[Pubkey], ) -> TaskInfoFetcherResult> { let committed_pubkeys = accounts .iter() @@ -115,7 +116,11 @@ impl TaskBuilderImpl { .collect::>(); task_info_fetcher - .fetch_next_commit_nonces(&committed_pubkeys, min_context_slot) + .fetch_next_commit_nonces_with_missing_as_zero( + &committed_pubkeys, + min_context_slot, + missing_metadata_as_zero, + ) .await } @@ -148,12 +153,8 @@ impl TaskBuilderImpl { .rent_pending_ata_materializations .iter() .map(|materialization| materialization.eata_pubkey) - .collect::>(); - let all_committed_accounts = intent_bundle - .get_all_committed_accounts() - .into_iter() - .filter(|account| !rent_pending_pubkeys.contains(&account.pubkey)) .collect::>(); + let all_committed_accounts = intent_bundle.get_all_committed_accounts(); // Get commit nonces and base accounts let min_context_slot = all_committed_accounts .iter() @@ -164,7 +165,8 @@ impl TaskBuilderImpl { Self::fetch_commit_nonces( task_info_fetcher, &all_committed_accounts, - min_context_slot + min_context_slot, + &rent_pending_pubkeys ), Self::fetch_diffable_accounts( task_info_fetcher, @@ -172,14 +174,8 @@ impl TaskBuilderImpl { min_context_slot ) ); - let mut commit_nonces = + let commit_nonces = commit_ids.map_err(TaskBuilderError::CommitTasksBuildError)?; - for materialization in &intent_bundle - .intent_bundle - .rent_pending_ata_materializations - { - commit_nonces.insert(materialization.eata_pubkey, 1); - } let base_accounts = base_accounts.unwrap_or_else(|err| { tracing::warn!(intent_id = intent_bundle.id, error = ?err, "Failed to fetch base accounts, falling back to CommitState"); Default::default() @@ -365,30 +361,48 @@ impl TasksBuilder for TaskBuilderImpl { async fn create_undelegate_tasks( commit_and_undelegate: &CommitAndUndelegate, info_fetcher: &Arc, + rent_pending_reimbursements: &HashMap, ) -> TaskBuilderResult> { // Get rent reimbursments for undelegated accounts let accounts = commit_and_undelegate.get_committed_accounts(); let mut min_context_slot = 0; let pubkeys = accounts .iter() - .map(|account| { + .filter_map(|account| { min_context_slot = std::cmp::max(min_context_slot, account.remote_slot); - account.pubkey + (!rent_pending_reimbursements.contains_key(&account.pubkey)) + .then_some(account.pubkey) }) .collect::>(); - let rent_reimbursements = info_fetcher + let fetched_rent_reimbursements = info_fetcher .fetch_rent_reimbursements(&pubkeys, min_context_slot) .await .map_err(TaskBuilderError::FinalizedTasksBuildError)?; - - let mut tasks = accounts - .iter() - .zip(rent_reimbursements) - .map(|(account, rent_reimbursement)| { - undelegate_task(account, &rent_reimbursement) - }) - .collect::>(); + let rent_reimbursements = pubkeys + .into_iter() + .zip(fetched_rent_reimbursements) + .collect::>(); + + let mut tasks = Vec::with_capacity(accounts.len()); + for account in accounts { + let rent_reimbursement = if let Some(rent_reimbursement) = + rent_pending_reimbursements.get(&account.pubkey) + { + *rent_reimbursement + } else if let Some(rent_reimbursement) = + rent_reimbursements.get(&account.pubkey) + { + *rent_reimbursement + } else { + return Err(TaskBuilderError::FinalizedTasksBuildError( + TaskInfoFetcherError::AccountNotFoundError( + account.pubkey, + ), + )); + }; + tasks.push(undelegate_task(account, &rent_reimbursement)); + } if let UndelegateType::WithBaseActions(actions) = &commit_and_undelegate.undelegate_action @@ -399,6 +413,14 @@ impl TasksBuilder for TaskBuilderImpl { } let mut tasks = Vec::new(); + let rent_pending_reimbursements = intent_bundle + .intent_bundle + .rent_pending_ata_materializations + .iter() + .map(|materialization| { + (materialization.eata_pubkey, materialization.validator) + }) + .collect::>(); let mut futures = Vec::with_capacity(2); if let Some(ref value) = intent_bundle.intent_bundle.commit { @@ -409,13 +431,21 @@ impl TasksBuilder for TaskBuilderImpl { intent_bundle.intent_bundle.commit_and_undelegate { tasks.extend(create_finalize_tasks(&value.commit_action)); - futures.push(create_undelegate_tasks(value, info_fetcher)); + futures.push(create_undelegate_tasks( + value, + info_fetcher, + &rent_pending_reimbursements, + )); } if let Some(ref value) = intent_bundle.intent_bundle.commit_finalize_and_undelegate { - futures.push(create_undelegate_tasks(value, info_fetcher)); + futures.push(create_undelegate_tasks( + value, + info_fetcher, + &rent_pending_reimbursements, + )); } tasks.extend(try_join_all(futures).await?.into_iter().flatten()); @@ -594,12 +624,26 @@ mod tests { #[async_trait] impl TaskInfoFetcher for EmptyFetcher { async fn fetch_next_commit_nonces( + &self, + pubkeys: &[Pubkey], + min_context_slot: u64, + ) -> TaskInfoFetcherResult> { + self.fetch_next_commit_nonces_with_missing_as_zero( + pubkeys, + min_context_slot, + pubkeys, + ) + .await + } + + async fn fetch_next_commit_nonces_with_missing_as_zero( &self, pubkeys: &[Pubkey], _min_context_slot: u64, + missing_metadata_as_zero: &[Pubkey], ) -> TaskInfoFetcherResult> { - assert!(pubkeys.is_empty()); - Ok(HashMap::new()) + assert_eq!(pubkeys, missing_metadata_as_zero); + Ok(pubkeys.iter().map(|pubkey| (*pubkey, 1)).collect()) } async fn fetch_current_commit_nonces( @@ -613,9 +657,72 @@ mod tests { async fn fetch_rent_reimbursements( &self, - _pubkeys: &[Pubkey], + pubkeys: &[Pubkey], _min_context_slot: u64, ) -> TaskInfoFetcherResult> { + assert!(pubkeys.is_empty()); + Ok(Vec::new()) + } + + async fn get_base_accounts( + &self, + pubkeys: &[Pubkey], + _min_context_slot: u64, + ) -> TaskInfoFetcherResult> { + assert!(pubkeys.is_empty()); + Ok(HashMap::new()) + } + } + + struct ExistingMetadataFetcher { + next_nonce: u64, + } + + #[async_trait] + impl TaskInfoFetcher for ExistingMetadataFetcher { + async fn fetch_next_commit_nonces( + &self, + pubkeys: &[Pubkey], + min_context_slot: u64, + ) -> TaskInfoFetcherResult> { + self.fetch_next_commit_nonces_with_missing_as_zero( + pubkeys, + min_context_slot, + &[], + ) + .await + } + + async fn fetch_next_commit_nonces_with_missing_as_zero( + &self, + pubkeys: &[Pubkey], + _min_context_slot: u64, + missing_metadata_as_zero: &[Pubkey], + ) -> TaskInfoFetcherResult> { + assert_eq!(pubkeys, missing_metadata_as_zero); + Ok(pubkeys + .iter() + .map(|pubkey| (*pubkey, self.next_nonce)) + .collect()) + } + + async fn fetch_current_commit_nonces( + &self, + pubkeys: &[Pubkey], + _min_context_slot: u64, + ) -> TaskInfoFetcherResult> { + Ok(pubkeys + .iter() + .map(|pubkey| (*pubkey, self.next_nonce.saturating_sub(1))) + .collect()) + } + + async fn fetch_rent_reimbursements( + &self, + pubkeys: &[Pubkey], + _min_context_slot: u64, + ) -> TaskInfoFetcherResult> { + assert!(pubkeys.is_empty()); Ok(Vec::new()) } @@ -748,6 +855,45 @@ mod tests { assert_eq!(&delegate_ix.data[1..], validator.as_ref()); } + #[tokio::test] + async fn rent_pending_commit_and_undelegate_finalize_defaults_reimbursement( + ) { + let fetcher = Arc::new(EmptyFetcher); + let (intent, validator) = rent_pending_intent(true); + + let tasks = TaskBuilderImpl::finalize_tasks(&fetcher, &intent) + .await + .unwrap(); + + assert!(matches!( + tasks.as_slice(), + [BaseTaskImpl::Finalize(_), BaseTaskImpl::Undelegate(_)] + )); + let BaseTaskImpl::Undelegate(undelegate) = &tasks[1] else { + panic!("expected undelegate task"); + }; + assert_eq!(undelegate.rent_reimbursement, validator); + } + + #[tokio::test] + async fn rent_pending_second_commit_uses_fetched_nonce() { + let fetcher = Arc::new(ExistingMetadataFetcher { next_nonce: 2 }); + let (intent, _validator) = rent_pending_intent(false); + + let tasks = TaskBuilderImpl::commit_tasks( + &fetcher, + &intent, + &None::, + ) + .await + .unwrap(); + + let BaseTaskImpl::Commit(commit) = &tasks[2] else { + panic!("expected commit task"); + }; + assert_eq!(commit.commit_id, 2); + } + #[tokio::test] async fn rent_pending_conflicting_base_delegation_is_gated_by_eata_delegate( ) { diff --git a/magicblock-committor-service/src/tasks/task_strategist.rs b/magicblock-committor-service/src/tasks/task_strategist.rs index b55cbd7dc..e7f80fbd7 100644 --- a/magicblock-committor-service/src/tasks/task_strategist.rs +++ b/magicblock-committor-service/src/tasks/task_strategist.rs @@ -509,6 +509,15 @@ mod tests { Ok(pubkeys.iter().map(|pubkey| (*pubkey, 0)).collect()) } + async fn fetch_next_commit_nonces_with_missing_as_zero( + &self, + pubkeys: &[Pubkey], + _: u64, + _: &[Pubkey], + ) -> TaskInfoFetcherResult> { + self.fetch_next_commit_nonces(pubkeys, 0).await + } + async fn fetch_current_commit_nonces( &self, pubkeys: &[Pubkey], diff --git a/programs/magicblock/src/ephemeral_accounts/process_create_rent_pending_ata.rs b/programs/magicblock/src/ephemeral_accounts/process_create_rent_pending_ata.rs index 4f925b336..cbd7d98d2 100644 --- a/programs/magicblock/src/ephemeral_accounts/process_create_rent_pending_ata.rs +++ b/programs/magicblock/src/ephemeral_accounts/process_create_rent_pending_ata.rs @@ -19,7 +19,8 @@ use spl_token::state::{ }; use spl_token_2022::{ extension::{ - BaseStateWithExtensions, StateWithExtensions, StateWithExtensionsMut, + BaseStateWithExtensions, BaseStateWithExtensionsMut, ExtensionType, + StateWithExtensions, StateWithExtensionsMut, }, state::{ Account as Token2022Account, AccountState as Token2022AccountState, @@ -36,6 +37,11 @@ const ATA_IDX: u16 = 1; const MINT_IDX: u16 = 2; const TOKEN_PROGRAM_IDX: u16 = 3; +struct TokenAccountShape { + len: usize, + required_extensions: Vec, +} + pub(crate) fn process_create_rent_pending_ata( invoke_context: &InvokeContext, transaction_context: &TransactionContext, @@ -97,23 +103,24 @@ pub(crate) fn process_create_rent_pending_ata( let mint_acc = get_instruction_account_with_idx(transaction_context, MINT_IDX)?; - let mint_data_len = { + let token_account_shape = { let mint_acc = mint_acc.borrow()?; if mint_acc.owner() != &token_program { return Err(InstructionError::InvalidAccountOwner); } - token_account_len(mint_acc.data(), &token_program)? + token_account_shape(mint_acc.data(), &token_program)? }; let mut acc = ata.borrow_mut()?; acc.set_lamports(0); acc.set_owner(token_program); - acc.resize(mint_data_len, 0); + acc.resize(token_account_shape.len, 0); initialize_token_data( acc.data_as_mut_slice(), &token_program, wallet_owner, mint, + &token_account_shape.required_extensions, )?; acc.set_remote_slot(0); acc.set_delegated(true); @@ -147,19 +154,35 @@ fn is_native_mint(mint: &Pubkey, token_program: &Pubkey) -> bool { && *mint == spl_token_2022::native_mint::id()) } -fn token_account_len( +fn token_account_shape( mint_data: &[u8], token_program: &Pubkey, -) -> Result { +) -> Result { if *token_program == TOKEN_PROGRAM_ID { SplMint::unpack(mint_data) .map_err(|_| InstructionError::InvalidAccountData)?; - Ok(SplAccount::LEN) + Ok(TokenAccountShape { + len: SplAccount::LEN, + required_extensions: Vec::new(), + }) } else { let mint = StateWithExtensions::::unpack(mint_data) .map_err(|_| InstructionError::InvalidAccountData)?; - mint.try_get_account_len() - .map_err(|_| InstructionError::InvalidAccountData) + let mint_extensions = mint + .get_extension_types() + .map_err(|_| InstructionError::InvalidAccountData)?; + let required_extensions = + ExtensionType::get_required_init_account_extensions( + &mint_extensions, + ); + let len = ExtensionType::try_calculate_account_len::( + &required_extensions, + ) + .map_err(|_| InstructionError::InvalidAccountData)?; + Ok(TokenAccountShape { + len, + required_extensions, + }) } } @@ -168,6 +191,7 @@ fn initialize_token_data( token_program: &Pubkey, wallet_owner: Pubkey, mint: Pubkey, + required_extensions: &[ExtensionType], ) -> Result<(), InstructionError> { if *token_program == TOKEN_PROGRAM_ID { let account = SplAccount { @@ -184,8 +208,15 @@ fn initialize_token_data( .map_err(|_| InstructionError::InvalidAccountData) } else { let mut state = - StateWithExtensionsMut::::unpack(data) + StateWithExtensionsMut::::unpack_uninitialized( + data, + ) + .map_err(|_| InstructionError::InvalidAccountData)?; + for extension in required_extensions { + state + .init_account_extension_from_type(*extension) .map_err(|_| InstructionError::InvalidAccountData)?; + } state.base = Token2022Account { mint, owner: wallet_owner, @@ -197,7 +228,9 @@ fn initialize_token_data( close_authority: COption::Some(RENT_PENDING_ATA_CLOSE_AUTHORITY), }; state.pack_base(); - Ok(()) + state + .init_account_type() + .map_err(|_| InstructionError::InvalidAccountData) } } @@ -236,6 +269,10 @@ mod tests { use solana_account::{AccountSharedData, ReadableAccount, WritableAccount}; use solana_instruction::{AccountMeta, Instruction}; use solana_sdk_ids::{native_loader, system_program}; + use spl_token_2022::extension::{ + immutable_owner::ImmutableOwner, + non_transferable::{NonTransferable, NonTransferableAccount}, + }; use super::*; use crate::test_utils::process_instruction; @@ -254,6 +291,40 @@ mod tests { account } + fn token_2022_mint_account( + extension_types: &[ExtensionType], + ) -> AccountSharedData { + let len = ExtensionType::try_calculate_account_len::( + extension_types, + ) + .unwrap(); + let mut account = + AccountSharedData::new(1_000_000, len, &TOKEN_2022_PROGRAM_ID); + let mut state = + StateWithExtensionsMut::::unpack_uninitialized( + account.data_as_mut_slice(), + ) + .unwrap(); + for extension_type in extension_types { + match extension_type { + ExtensionType::NonTransferable => { + state.init_extension::(false).unwrap(); + } + _ => panic!("unsupported test extension"), + } + } + state.base = Token2022Mint { + mint_authority: COption::None, + supply: 0, + decimals: 6, + is_initialized: true, + freeze_authority: COption::None, + }; + state.pack_base(); + state.init_account_type().unwrap(); + account + } + #[test] fn create_rent_pending_ata_initializes_local_token_account_shape() { let payer = Pubkey::new_unique(); @@ -318,4 +389,138 @@ mod tests { COption::Some(RENT_PENDING_ATA_CLOSE_AUTHORITY) ); } + + #[test] + fn create_rent_pending_token_2022_ata_initializes_local_token_account_shape( + ) { + let payer = Pubkey::new_unique(); + let wallet_owner = Pubkey::new_unique(); + let mint = Pubkey::new_unique(); + let ata = derive_ata_with_token_program( + &wallet_owner, + &mint, + &TOKEN_2022_PROGRAM_ID, + ); + + let ix = Instruction::new_with_bincode( + crate::id(), + &MagicBlockInstruction::CreateRentPendingAta { + wallet_owner, + mint, + token_program: TOKEN_2022_PROGRAM_ID, + }, + vec![ + AccountMeta::new(payer, true), + AccountMeta::new(ata, false), + AccountMeta::new_readonly(mint, false), + AccountMeta::new_readonly(TOKEN_2022_PROGRAM_ID, false), + ], + ); + let accounts = process_instruction( + &ix.data, + vec![ + ( + payer, + AccountSharedData::new(1_000_000, 0, &system_program::id()), + ), + (ata, AccountSharedData::new(0, 0, &system_program::id())), + (mint, token_2022_mint_account(&[])), + ( + TOKEN_2022_PROGRAM_ID, + AccountSharedData::new(1, 0, &native_loader::id()), + ), + ], + ix.accounts, + Ok(()), + ); + + let ata_after = &accounts[1]; + assert_eq!(ata_after.owner(), &TOKEN_2022_PROGRAM_ID); + assert_eq!(ata_after.data().len(), Token2022Account::LEN); + let token_account = + StateWithExtensions::::unpack(ata_after.data()) + .unwrap(); + assert_eq!(token_account.base.mint, mint); + assert_eq!(token_account.base.owner, wallet_owner); + assert_eq!(token_account.base.amount, 0); + assert_eq!( + token_account.base.close_authority, + COption::Some(RENT_PENDING_ATA_CLOSE_AUTHORITY) + ); + + let info = try_get_rent_pending_ata_info(&ata, ata_after).unwrap(); + assert_eq!(info.token_program, TOKEN_2022_PROGRAM_ID); + assert_eq!(info.wallet_owner, wallet_owner); + assert_eq!(info.mint, mint); + } + + #[test] + fn create_rent_pending_token_2022_ata_initializes_required_extensions() { + let payer = Pubkey::new_unique(); + let wallet_owner = Pubkey::new_unique(); + let mint = Pubkey::new_unique(); + let ata = derive_ata_with_token_program( + &wallet_owner, + &mint, + &TOKEN_2022_PROGRAM_ID, + ); + + let ix = Instruction::new_with_bincode( + crate::id(), + &MagicBlockInstruction::CreateRentPendingAta { + wallet_owner, + mint, + token_program: TOKEN_2022_PROGRAM_ID, + }, + vec![ + AccountMeta::new(payer, true), + AccountMeta::new(ata, false), + AccountMeta::new_readonly(mint, false), + AccountMeta::new_readonly(TOKEN_2022_PROGRAM_ID, false), + ], + ); + let accounts = process_instruction( + &ix.data, + vec![ + ( + payer, + AccountSharedData::new(1_000_000, 0, &system_program::id()), + ), + (ata, AccountSharedData::new(0, 0, &system_program::id())), + ( + mint, + token_2022_mint_account(&[ExtensionType::NonTransferable]), + ), + ( + TOKEN_2022_PROGRAM_ID, + AccountSharedData::new(1, 0, &native_loader::id()), + ), + ], + ix.accounts, + Ok(()), + ); + + let expected_extensions = [ + ExtensionType::NonTransferableAccount, + ExtensionType::ImmutableOwner, + ]; + let expected_len = ExtensionType::try_calculate_account_len::< + Token2022Account, + >(&expected_extensions) + .unwrap(); + let ata_after = &accounts[1]; + assert_eq!(ata_after.data().len(), expected_len); + let token_account = + StateWithExtensions::::unpack(ata_after.data()) + .unwrap(); + assert!(token_account + .get_extension::() + .is_ok()); + assert!(token_account.get_extension::().is_ok()); + assert_eq!( + token_account.base.close_authority, + COption::Some(RENT_PENDING_ATA_CLOSE_AUTHORITY) + ); + assert!(try_get_rent_pending_ata_info(&ata, ata_after).is_some()); + } } diff --git a/programs/magicblock/src/magic_context.rs b/programs/magicblock/src/magic_context.rs index 51d819db0..519ee3411 100644 --- a/programs/magicblock/src/magic_context.rs +++ b/programs/magicblock/src/magic_context.rs @@ -1,10 +1,20 @@ use std::{io::Cursor, mem}; +use magicblock_core::{ + token_programs::{TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID}, + Slot, +}; use magicblock_magic_program_api::MAGIC_CONTEXT_SIZE; -use serde::{Deserialize, Serialize}; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use solana_hash::Hash; use solana_instruction::error::InstructionError; +use solana_pubkey::Pubkey; +use solana_transaction::Transaction; -use crate::magic_scheduled_base_intent::ScheduledIntentBundle; +use crate::magic_scheduled_base_intent::{ + BaseAction, CommitAndUndelegate, CommitType, MagicIntentBundle, + ScheduledIntentBundle, +}; #[derive(Debug, Default, Serialize, Deserialize)] pub struct MagicContext { @@ -20,10 +30,21 @@ impl MagicContext { if data.is_empty() || is_zeroed(data) { Ok(Self::default()) } else { - bincode::deserialize_from(Cursor::new(data)) + match deserialize_with_zero_padding(data) { + Ok(context) if has_valid_rent_pending_metadata(&context) => { + Ok(context) + } + Ok(_) => Self::deserialize_legacy(data), + Err(err) => Self::deserialize_legacy(data).map_err(|_| err), + } } } + fn deserialize_legacy(data: &[u8]) -> Result { + deserialize_with_zero_padding::(data) + .map(Into::into) + } + pub(crate) fn write_to( &self, data: &mut [u8], @@ -88,9 +109,121 @@ fn is_zeroed(buf: &[u8]) -> bool { } } +fn deserialize_with_zero_padding( + data: &[u8], +) -> Result { + let value = bincode::deserialize_from(Cursor::new(data))?; + let encoded = bincode::serialize(&value)?; + if data.get(..encoded.len()) == Some(encoded.as_slice()) + && data.get(encoded.len()..).is_some_and(is_zeroed) + { + Ok(value) + } else { + Err(Box::new(bincode::ErrorKind::Custom( + "non-zero trailing bytes".to_string(), + ))) + } +} + +fn has_valid_rent_pending_metadata(context: &MagicContext) -> bool { + context.scheduled_base_intents.iter().all(|intent| { + intent + .intent_bundle + .rent_pending_ata_materializations + .iter() + .all(|materialization| { + materialization.ata_pubkey != Pubkey::default() + && materialization.eata_pubkey != Pubkey::default() + && materialization.wallet_owner != Pubkey::default() + && materialization.mint != Pubkey::default() + && materialization.validator != Pubkey::default() + && materialization.delegated_payer != Pubkey::default() + && materialization.delegated_vault != Pubkey::default() + && materialization.token_account_data_len != 0 + && (materialization.token_program == TOKEN_PROGRAM_ID + || materialization.token_program + == TOKEN_2022_PROGRAM_ID) + }) + }) +} + +// Compat invariant: pre-rent-pending MagicContext bytes do not contain +// `rent_pending_ata_materializations`; restored legacy intents use an empty vec. +#[derive(Serialize, Deserialize)] +struct LegacyMagicContext { + intent_id: u64, + scheduled_base_intents: Vec, +} + +#[derive(Serialize, Deserialize)] +struct LegacyScheduledIntentBundle { + id: u64, + slot: Slot, + blockhash: Hash, + sent_transaction: Transaction, + payer: Pubkey, + intent_bundle: LegacyMagicIntentBundle, +} + +#[derive(Serialize, Deserialize)] +struct LegacyMagicIntentBundle { + commit: Option, + commit_and_undelegate: Option, + commit_finalize: Option, + commit_finalize_and_undelegate: Option, + standalone_actions: Vec, +} + +impl From for MagicContext { + fn from(value: LegacyMagicContext) -> Self { + Self { + intent_id: value.intent_id, + scheduled_base_intents: value + .scheduled_base_intents + .into_iter() + .map(Into::into) + .collect(), + } + } +} + +impl From for ScheduledIntentBundle { + fn from(value: LegacyScheduledIntentBundle) -> Self { + Self { + id: value.id, + slot: value.slot, + blockhash: value.blockhash, + sent_transaction: value.sent_transaction, + payer: value.payer, + intent_bundle: value.intent_bundle.into(), + } + } +} + +impl From for MagicIntentBundle { + fn from(value: LegacyMagicIntentBundle) -> Self { + Self { + commit: value.commit, + commit_and_undelegate: value.commit_and_undelegate, + commit_finalize: value.commit_finalize, + commit_finalize_and_undelegate: value + .commit_finalize_and_undelegate, + standalone_actions: value.standalone_actions, + rent_pending_ata_materializations: Vec::new(), + } + } +} + #[cfg(test)] mod tests { - use super::MagicContext; + use solana_hash::Hash; + use solana_pubkey::Pubkey; + use solana_transaction::Transaction; + + use super::{ + LegacyMagicContext, LegacyMagicIntentBundle, + LegacyScheduledIntentBundle, MagicContext, + }; #[test] fn deserialize_treats_empty_and_zeroed_as_default() { @@ -114,4 +247,57 @@ mod tests { assert_eq!(got.intent_id, ctx.intent_id); assert!(got.scheduled_base_intents.is_empty()); } + + #[test] + fn deserialize_recovers_legacy_context_with_two_pending_intents() { + let legacy = LegacyMagicContext { + intent_id: 11, + scheduled_base_intents: vec![ + LegacyScheduledIntentBundle { + id: 1, + slot: 2, + blockhash: Hash::new_unique(), + sent_transaction: Transaction::default(), + payer: Pubkey::new_unique(), + intent_bundle: LegacyMagicIntentBundle { + commit: None, + commit_and_undelegate: None, + commit_finalize: None, + commit_finalize_and_undelegate: None, + standalone_actions: Vec::new(), + }, + }, + LegacyScheduledIntentBundle { + id: 999, + slot: 3, + blockhash: Hash::new_unique(), + sent_transaction: Transaction::default(), + payer: Pubkey::new_unique(), + intent_bundle: LegacyMagicIntentBundle { + commit: None, + commit_and_undelegate: None, + commit_finalize: None, + commit_finalize_and_undelegate: None, + standalone_actions: Vec::new(), + }, + }, + ], + }; + let encoded = bincode::serialize(&legacy).unwrap(); + let mut data = vec![0; MagicContext::SIZE]; + data[..encoded.len()].copy_from_slice(&encoded); + + let got = MagicContext::deserialize(&data).unwrap(); + + assert_eq!(got.intent_id, 11); + assert_eq!(got.scheduled_base_intents.len(), 2); + assert_eq!(got.scheduled_base_intents[0].id, 1); + assert_eq!(got.scheduled_base_intents[1].id, 999); + assert!(got.scheduled_base_intents.iter().all(|intent| { + intent + .intent_bundle + .rent_pending_ata_materializations + .is_empty() + })); + } } diff --git a/test-integration/test-committor-service/tests/common.rs b/test-integration/test-committor-service/tests/common.rs index 66f896211..acffcfe81 100644 --- a/test-integration/test-committor-service/tests/common.rs +++ b/test-integration/test-committor-service/tests/common.rs @@ -176,6 +176,15 @@ impl TaskInfoFetcher for MockTaskInfoFetcher { Ok(pubkeys.iter().map(|pubkey| (*pubkey, 0)).collect()) } + async fn fetch_next_commit_nonces_with_missing_as_zero( + &self, + pubkeys: &[Pubkey], + _: u64, + _: &[Pubkey], + ) -> TaskInfoFetcherResult> { + self.fetch_next_commit_nonces(pubkeys, 0).await + } + async fn fetch_current_commit_nonces( &self, pubkeys: &[Pubkey], From c4629a226cdea0af8c90b3000ed892d32e99e3a9 Mon Sep 17 00:00:00 2001 From: Gabriele Picco Date: Fri, 3 Jul 2026 18:57:39 +0800 Subject: [PATCH 04/16] fix: address PR review findings for rent-pending ATA flow - Derive rent-pending materializations from committed eATA rows on committor recovery: prepend init/delegate only when the base delegation-metadata PDA is actually missing, and default undelegate rent reimbursement to the validator for derived entries, making the flow restart-recoverable without persisting metadata - Reject default wallet owner/mint in rent-pending detection and creation: default pubkeys are reserved as the MagicContext legacy-decode sentinel and must never be written as materializations - Reuse the pre-construction fee-vault decision in process_schedule_intent_bundle so undelegating the payer mid-bundle cannot skip the fee charge - Add regression tests: rent-pending commit without fee vault is rejected, detection negative gates, default owner/mint rejection - Clarify missing-metadata nonce semantics in the fetcher docs --- .../src/intent_executor/mod.rs | 4 + .../src/intent_executor/task_info_fetcher.rs | 326 +++++++++++++--- .../src/tasks/task_builder.rs | 360 ++++++++++++++++-- .../src/tasks/task_strategist.rs | 26 +- magicblock-core/src/token_programs.rs | 69 ++++ .../tests/ephemeral_accounts.rs | 3 +- .../process_create_rent_pending_ata.rs | 4 + .../process_schedule_commit_tests.rs | 59 +++ .../process_schedule_intent_bundle.rs | 16 +- 9 files changed, 754 insertions(+), 113 deletions(-) diff --git a/magicblock-committor-service/src/intent_executor/mod.rs b/magicblock-committor-service/src/intent_executor/mod.rs index d71c654ef..5427a78dd 100644 --- a/magicblock-committor-service/src/intent_executor/mod.rs +++ b/magicblock-committor-service/src/intent_executor/mod.rs @@ -203,6 +203,7 @@ where return Err(IntentExecutorError::EmptyIntentError); } let all_committed_pubkeys = intent_bundle.get_all_committed_pubkeys(); + let validator = self.authority.pubkey(); // Update tasks status to Pending { @@ -222,6 +223,7 @@ where let commit_tasks = TaskBuilderImpl::commit_tasks( &self.task_info_fetcher, &intent_bundle, + &validator, persister, ) .await?; @@ -248,11 +250,13 @@ where let commit_tasks_fut = TaskBuilderImpl::commit_tasks( &self.task_info_fetcher, &intent_bundle, + &validator, persister, ); let finalize_tasks_fut = TaskBuilderImpl::finalize_tasks( &self.task_info_fetcher, &intent_bundle, + &validator, ); let (commit_tasks, finalize_tasks) = join(commit_tasks_fut, finalize_tasks_fut).await; diff --git a/magicblock-committor-service/src/intent_executor/task_info_fetcher.rs b/magicblock-committor-service/src/intent_executor/task_info_fetcher.rs index 9305c6e88..a2138dd04 100644 --- a/magicblock-committor-service/src/intent_executor/task_info_fetcher.rs +++ b/magicblock-committor-service/src/intent_executor/task_info_fetcher.rs @@ -38,14 +38,15 @@ pub trait TaskInfoFetcher: Send + Sync + 'static { min_context_slot: u64, ) -> TaskInfoFetcherResult>; - /// Fetches next commit nonces, using nonce 1 when allowed metadata is - /// missing on base. Non-allowed missing metadata remains an error. + /// Fetches next commit nonces. For pubkeys in `missing_metadata_as_zero`, + /// a missing delegation-metadata account is treated as current nonce 0 + /// (yielding next nonce 1); any other missing metadata remains an error. async fn fetch_next_commit_nonces_with_missing_as_zero( &self, pubkeys: &[Pubkey], min_context_slot: u64, missing_metadata_as_zero: &[Pubkey], - ) -> TaskInfoFetcherResult>; + ) -> TaskInfoFetcherResult; /// Fetches current commit nonces for pubkeys /// Missing nonces will be fetched from chain @@ -62,6 +63,44 @@ pub trait TaskInfoFetcher: Send + Sync + 'static { min_context_slot: u64, ) -> TaskInfoFetcherResult>; + /// Fetches rent reimbursement addresses, using caller-provided defaults + /// when allowed metadata is missing on base. + async fn fetch_rent_reimbursements_with_missing_as( + &self, + pubkeys: &[Pubkey], + min_context_slot: u64, + missing_metadata_as: &HashMap, + ) -> TaskInfoFetcherResult> { + let reimbursements = self + .fetch_rent_reimbursements(pubkeys, min_context_slot) + .await?; + let result = pubkeys + .iter() + .copied() + .zip(reimbursements) + .collect::>(); + if result.len() == pubkeys.len() { + return Ok(result); + } + + let missing_pubkeys = pubkeys + .iter() + .copied() + .filter(|pubkey| !result.contains_key(pubkey)) + .collect::>(); + let mut result = result; + for missing_pubkey in missing_pubkeys { + if let Some(default) = missing_metadata_as.get(&missing_pubkey) { + result.insert(missing_pubkey, *default); + } else { + return Err(TaskInfoFetcherError::AccountNotFoundError( + missing_pubkey, + )); + } + } + Ok(result) + } + async fn get_base_accounts( &self, pubkeys: &[Pubkey], @@ -69,6 +108,22 @@ pub trait TaskInfoFetcher: Send + Sync + 'static { ) -> TaskInfoFetcherResult>; } +#[derive(Debug, Default)] +pub struct CommitNonceFetchResult { + pub nonces: HashMap, + pub missing_metadata: HashSet, +} + +impl CommitNonceFetchResult { + #[cfg(test)] + pub(crate) fn from_nonces(nonces: HashMap) -> Self { + Self { + nonces, + missing_metadata: HashSet::new(), + } + } +} + // --------------------------------------------------------------------------- // RpcTaskInfoFetcher // --------------------------------------------------------------------------- @@ -133,9 +188,9 @@ impl RpcTaskInfoFetcher { min_context_slot: u64, max_retries: NonZeroUsize, missing_metadata_as_zero: &[Pubkey], - ) -> TaskInfoFetcherResult> { + ) -> TaskInfoFetcherResult { if pubkeys.is_empty() { - return Ok(HashMap::new()); + return Ok(CommitNonceFetchResult::default()); } let zero_defaults: HashSet<_> = @@ -162,6 +217,7 @@ impl RpcTaskInfoFetcher { .await?; let mut nonces = HashMap::with_capacity(pubkeys.len()); + let mut missing_metadata = HashSet::new(); for ((delegated_pubkey, pda), account) in pubkeys.iter().copied().zip(pda_accounts).zip(accounts) { @@ -175,7 +231,10 @@ impl RpcTaskInfoFetcher { })? .last_update_nonce } - None if zero_defaults.contains(&delegated_pubkey) => 0, + None if zero_defaults.contains(&delegated_pubkey) => { + missing_metadata.insert(delegated_pubkey); + 0 + } None => { return Err(TaskInfoFetcherError::AccountNotFoundError( pda, @@ -185,7 +244,74 @@ impl RpcTaskInfoFetcher { nonces.insert(delegated_pubkey, nonce); } - Ok(nonces) + Ok(CommitNonceFetchResult { + nonces, + missing_metadata, + }) + } + + async fn fetch_rent_reimbursements_map_with_missing_as( + rpc_client: &MagicblockRpcClient, + pubkeys: &[Pubkey], + min_context_slot: u64, + max_retries: NonZeroUsize, + missing_metadata_as: &HashMap, + ) -> TaskInfoFetcherResult> { + if pubkeys.is_empty() { + return Ok(HashMap::new()); + } + + let pda_accounts: Vec = pubkeys + .iter() + .map(|delegated_account| { + Pubkey::find_program_address( + delegation_metadata_seeds_from_delegated_account!( + delegated_account + ), + &dlp_api::id(), + ) + .0 + }) + .collect(); + + let accounts = Self::fetch_optional_accounts_with_retries( + rpc_client, + &pda_accounts, + min_context_slot, + max_retries, + ) + .await?; + + let mut reimbursements = HashMap::with_capacity(pubkeys.len()); + for ((delegated_pubkey, pda), account) in + pubkeys.iter().copied().zip(pda_accounts).zip(accounts) + { + let reimbursement = match account { + Some(account) => { + DelegationMetadata::try_from_bytes_with_discriminator( + &account.data, + ) + .map_err(|_| { + TaskInfoFetcherError::InvalidAccountDataError(pda) + })? + .rent_payer + } + None => { + if let Some(default) = + missing_metadata_as.get(&delegated_pubkey) + { + *default + } else { + return Err( + TaskInfoFetcherError::AccountNotFoundError(pda), + ); + } + } + }; + reimbursements.insert(delegated_pubkey, reimbursement); + } + + Ok(reimbursements) } /// Fetches [`Account`]s with some num of retries @@ -400,6 +526,7 @@ impl TaskInfoFetcher for RpcTaskInfoFetcher { &[], ) .await + .map(|result| result.nonces) } async fn fetch_next_commit_nonces_with_missing_as_zero( @@ -407,12 +534,12 @@ impl TaskInfoFetcher for RpcTaskInfoFetcher { pubkeys: &[Pubkey], min_context_slot: u64, missing_metadata_as_zero: &[Pubkey], - ) -> TaskInfoFetcherResult> { + ) -> TaskInfoFetcherResult { if pubkeys.is_empty() { - return Ok(HashMap::new()); + return Ok(CommitNonceFetchResult::default()); } - let nonces = Self::fetch_current_commit_nonces_with_missing_as_zero( + let result = Self::fetch_current_commit_nonces_with_missing_as_zero( &self.rpc_client, pubkeys, min_context_slot, @@ -420,10 +547,14 @@ impl TaskInfoFetcher for RpcTaskInfoFetcher { missing_metadata_as_zero, ) .await?; - Ok(nonces - .into_iter() - .map(|(pubkey, nonce)| (pubkey, nonce + 1)) - .collect()) + Ok(CommitNonceFetchResult { + nonces: result + .nonces + .into_iter() + .map(|(pubkey, nonce)| (pubkey, nonce + 1)) + .collect(), + missing_metadata: result.missing_metadata, + }) } async fn fetch_current_commit_nonces( @@ -451,16 +582,41 @@ impl TaskInfoFetcher for RpcTaskInfoFetcher { pubkeys: &[Pubkey], min_context_slot: u64, ) -> TaskInfoFetcherResult> { - Ok(Self::fetch_metadata_with_retries( + let reimbursements = + Self::fetch_rent_reimbursements_map_with_missing_as( + &self.rpc_client, + pubkeys, + min_context_slot, + NUM_FETCH_RETRIES, + &HashMap::new(), + ) + .await?; + + pubkeys + .iter() + .map(|pubkey| { + reimbursements + .get(pubkey) + .copied() + .ok_or(TaskInfoFetcherError::AccountNotFoundError(*pubkey)) + }) + .collect() + } + + async fn fetch_rent_reimbursements_with_missing_as( + &self, + pubkeys: &[Pubkey], + min_context_slot: u64, + missing_metadata_as: &HashMap, + ) -> TaskInfoFetcherResult> { + Self::fetch_rent_reimbursements_map_with_missing_as( &self.rpc_client, pubkeys, min_context_slot, NUM_FETCH_RETRIES, + missing_metadata_as, ) - .await? - .into_iter() - .map(|m| m.rent_payer) - .collect()) + .await } async fn get_base_accounts( @@ -479,8 +635,23 @@ impl TaskInfoFetcher for RpcTaskInfoFetcher { } } +#[derive(Clone, Copy, Debug, Default)] +struct CachedNonce { + value: Option, + missing_metadata: bool, +} + +impl CachedNonce { + fn new(value: u64, missing_metadata: bool) -> Self { + Self { + value: Some(value), + missing_metadata, + } + } +} + /// Per-account async mutex protecting the cached nonce value. -type NonceLock = Arc>; +type NonceLock = Arc>; /// Split-map cache: `active` is the live LRU window; `retiring` holds locks /// evicted from `active` that are still held by in-flight requests. @@ -510,7 +681,9 @@ impl<'a> CacheInnerGuard<'a> { // Acquire per-account locks sequentially in sorted order (see sort above). // join_all would poll all futures concurrently, allowing partial acquisition // and producing the classic A→B / B→A deadlock across concurrent callers. - async fn lock<'s>(&'s self) -> Vec<(&'s Pubkey, MutexGuard<'s, u64>)> { + async fn lock<'s>( + &'s self, + ) -> Vec<(&'s Pubkey, MutexGuard<'s, CachedNonce>)> { let mut output = Vec::with_capacity(self.nonce_locks.len()); for (pubkey, lock) in self.nonce_locks.iter() { let guard = lock.lock().await; @@ -584,9 +757,7 @@ impl CacheTaskInfoFetcher { nonce_locks: vec![(*pubkey, lock)], }; let guards = locks_guard.lock().await; - let value = *guards[0].1; - - (value != u64::MAX).then_some(value) + guards[0].1.value } /// Resets cache for some or all accounts @@ -627,7 +798,7 @@ impl CacheTaskInfoFetcher { let evicted = inner.active.push(pubkey, val.clone()); (val, evicted) } else { - let val = Arc::new(TMutex::new(u64::MAX)); + let val = Arc::new(TMutex::new(CachedNonce::default())); let evicted = inner.active.push(pubkey, val.clone()); (val, evicted) }; @@ -689,6 +860,7 @@ impl TaskInfoFetcher for CacheTaskInfoFetcher { &[], ) .await + .map(|result| result.nonces) } async fn fetch_next_commit_nonces_with_missing_as_zero( @@ -696,9 +868,9 @@ impl TaskInfoFetcher for CacheTaskInfoFetcher { pubkeys: &[Pubkey], min_context_slot: u64, missing_metadata_as_zero: &[Pubkey], - ) -> TaskInfoFetcherResult> { + ) -> TaskInfoFetcherResult { if pubkeys.is_empty() { - return Ok(HashMap::new()); + return Ok(CommitNonceFetchResult::default()); } // Acquire locks on requested nonces @@ -710,25 +882,37 @@ impl TaskInfoFetcher for CacheTaskInfoFetcher { let nonce_guards = locks_guard.lock().await; let (mut existing, mut missing) = (vec![], vec![]); for (pubkey, guard) in nonce_guards { - if *guard == u64::MAX { - missing.push((pubkey, guard)); + if guard.value.is_some() { + existing.push((pubkey, guard)); } else { - existing.push((pubkey, guard)) + missing.push((pubkey, guard)) } } + let zero_defaults: HashSet<_> = + missing_metadata_as_zero.iter().copied().collect(); // If all in cache - great! return if missing.is_empty() { - let mut result = HashMap::with_capacity(existing.len()); + let mut nonces = HashMap::with_capacity(existing.len()); + let mut missing_metadata = HashSet::new(); for (pubkey, mut guard) in existing { - *guard += 1; - result.insert(*pubkey, *guard); + if let Some(nonce) = guard.value.as_mut() { + *nonce += 1; + nonces.insert(*pubkey, *nonce); + if guard.missing_metadata && zero_defaults.contains(pubkey) + { + missing_metadata.insert(*pubkey); + } + } } - return Ok(result); + return Ok(CommitNonceFetchResult { + nonces, + missing_metadata, + }); } // Fetch missing nonces in cache - let fetched_nonces = { + let fetched_result = { let missing_pubkeys: Vec<_> = missing.iter().map(|(pubkey, _)| **pubkey).collect(); self.inner @@ -741,22 +925,36 @@ impl TaskInfoFetcher for CacheTaskInfoFetcher { }; // We don't care if anything changed in between with cache - just update and return our ids. - let mut result = HashMap::with_capacity(existing.len()); + let mut nonces = HashMap::with_capacity(existing.len()); + let mut missing_metadata = HashSet::new(); for (pubkey, mut guard) in existing { - *guard += 1; - result.insert(*pubkey, *guard); + if let Some(nonce) = guard.value.as_mut() { + *nonce += 1; + nonces.insert(*pubkey, *nonce); + if guard.missing_metadata && zero_defaults.contains(pubkey) { + missing_metadata.insert(*pubkey); + } + } } for (pubkey, mut guard) in missing { - if let Some(&nonce) = fetched_nonces.get(pubkey) { - *guard = nonce; - result.insert(*pubkey, *guard); + if let Some(&nonce) = fetched_result.nonces.get(pubkey) { + let was_missing = + fetched_result.missing_metadata.contains(pubkey); + *guard = CachedNonce::new(nonce, was_missing); + nonces.insert(*pubkey, nonce); + if was_missing { + missing_metadata.insert(*pubkey); + } Ok(()) } else { Err(TaskInfoFetcherError::AccountNotFoundError(*pubkey)) }?; } - Ok(result) + Ok(CommitNonceFetchResult { + nonces, + missing_metadata, + }) } async fn fetch_current_commit_nonces( @@ -776,10 +974,10 @@ impl TaskInfoFetcher for CacheTaskInfoFetcher { let mut missing = vec![]; let mut result = HashMap::with_capacity(nonce_guards.len()); for (pubkey, guard) in nonce_guards { - if *guard == u64::MAX { - missing.push((pubkey, guard)); + if let Some(nonce) = guard.value { + result.insert(*pubkey, nonce); } else { - result.insert(*pubkey, *guard); + missing.push((pubkey, guard)) } } @@ -801,7 +999,7 @@ impl TaskInfoFetcher for CacheTaskInfoFetcher { // increment from here correctly. for (pubkey, mut guard) in missing { if let Some(&nonce) = fetched_nonces.get(pubkey) { - *guard = nonce; + *guard = CachedNonce::new(nonce, false); result.insert(*pubkey, nonce); Ok(()) } else { @@ -822,6 +1020,21 @@ impl TaskInfoFetcher for CacheTaskInfoFetcher { .await } + async fn fetch_rent_reimbursements_with_missing_as( + &self, + pubkeys: &[Pubkey], + min_context_slot: u64, + missing_metadata_as: &HashMap, + ) -> TaskInfoFetcherResult> { + self.inner + .fetch_rent_reimbursements_with_missing_as( + pubkeys, + min_context_slot, + missing_metadata_as, + ) + .await + } + async fn get_base_accounts( &self, pubkeys: &[Pubkey], @@ -1189,6 +1402,7 @@ mod tests { &[], ) .await + .map(|result| result.nonces) } async fn fetch_next_commit_nonces_with_missing_as_zero( @@ -1196,19 +1410,21 @@ mod tests { pubkeys: &[Pubkey], _: u64, _: &[Pubkey], - ) -> TaskInfoFetcherResult> { + ) -> TaskInfoFetcherResult { if let Some(delay) = self.delay { tokio::time::sleep(delay).await; } let mut q = self.nonces.lock().unwrap(); - Ok(pubkeys - .iter() - .map(|pk| { - let nonce = - q.pop_front().expect("mock nonce queue exhausted"); - (*pk, nonce + 1) - }) - .collect()) + Ok(CommitNonceFetchResult::from_nonces( + pubkeys + .iter() + .map(|pk| { + let nonce = + q.pop_front().expect("mock nonce queue exhausted"); + (*pk, nonce + 1) + }) + .collect(), + )) } async fn fetch_current_commit_nonces( diff --git a/magicblock-committor-service/src/tasks/task_builder.rs b/magicblock-committor-service/src/tasks/task_builder.rs index 6fff689d3..1a7fb62a6 100644 --- a/magicblock-committor-service/src/tasks/task_builder.rs +++ b/magicblock-committor-service/src/tasks/task_builder.rs @@ -1,8 +1,17 @@ -use std::{collections::HashMap, sync::Arc}; +use std::{ + collections::{HashMap, HashSet}, + sync::Arc, +}; use async_trait::async_trait; use futures_util::future::try_join_all; -use magicblock_core::intent::CommittedAccount; +use magicblock_core::{ + intent::CommittedAccount, + token_programs::{ + try_derive_eata_address_and_bump, EphemeralAta, + RentPendingAtaMaterialization, EATA_PROGRAM_ID, + }, +}; use magicblock_program::magic_scheduled_base_intent::{ BaseAction, CommitAndUndelegate, CommitType, ScheduledIntentBundle, UndelegateType, @@ -14,7 +23,8 @@ use tracing::error; use crate::{ intent_executor::task_info_fetcher::{ - TaskInfoFetcher, TaskInfoFetcherError, TaskInfoFetcherResult, + CommitNonceFetchResult, TaskInfoFetcher, TaskInfoFetcherError, + TaskInfoFetcherResult, }, persist::IntentPersister, tasks::{ @@ -30,6 +40,7 @@ pub trait TasksBuilder { async fn commit_tasks( commit_id_fetcher: &Arc, base_intent: &ScheduledIntentBundle, + validator: &Pubkey, persister: &Option

, ) -> TaskBuilderResult>; @@ -37,6 +48,7 @@ pub trait TasksBuilder { async fn finalize_tasks( info_fetcher: &Arc, base_intent: &ScheduledIntentBundle, + validator: &Pubkey, ) -> TaskBuilderResult>; } @@ -46,6 +58,8 @@ pub struct CommitStageTaskInfo { commit_nonces: HashMap, /// Base account state for diff calculation base_accounts: HashMap, + /// Rent-pending eATAs that need same-transaction materialization + rent_pending_ata_materializations: Vec, } /// Task builder @@ -109,7 +123,7 @@ impl TaskBuilderImpl { accounts: &[CommittedAccount], min_context_slot: u64, missing_metadata_as_zero: &[Pubkey], - ) -> TaskInfoFetcherResult> { + ) -> TaskInfoFetcherResult { let committed_pubkeys = accounts .iter() .map(|account| account.pubkey) @@ -142,19 +156,101 @@ impl TaskBuilderImpl { .await } + fn derive_recovered_rent_pending_materialization( + account: &CommittedAccount, + validator: &Pubkey, + ) -> Option { + if account.account.owner != EATA_PROGRAM_ID { + return None; + } + + let eata = EphemeralAta::try_from_account_data(&account.account.data)?; + if eata.owner == Pubkey::default() || eata.mint == Pubkey::default() { + return None; + } + let (expected_eata, expected_bump) = + try_derive_eata_address_and_bump(&eata.owner, &eata.mint)?; + if expected_eata != account.pubkey || expected_bump != eata.bump { + return None; + } + + // Recovered rows only retain eATA state; the committor eATA tasks + // consume eATA, wallet owner, mint, and validator. + Some(RentPendingAtaMaterialization { + ata_pubkey: Pubkey::default(), + eata_pubkey: account.pubkey, + token_program: Pubkey::default(), + wallet_owner: eata.owner, + mint: eata.mint, + token_account_data_len: 0, + validator: *validator, + delegated_payer: Pubkey::default(), + delegated_vault: Pubkey::default(), + }) + } + + fn derive_recovered_rent_pending_materializations( + accounts: &[CommittedAccount], + validator: &Pubkey, + ) -> HashMap { + accounts + .iter() + .filter_map(|account| { + Self::derive_recovered_rent_pending_materialization( + account, validator, + ) + .map(|materialization| { + (materialization.eata_pubkey, materialization) + }) + }) + .collect() + } + + fn rent_pending_materialization_tasks( + materializations: impl IntoIterator, + ) -> Vec { + let mut seen = HashSet::new(); + materializations + .into_iter() + .filter(|materialization| seen.insert(materialization.eata_pubkey)) + .flat_map(|materialization| { + let task = RentPendingAtaTask { materialization }; + [ + BaseTaskImpl::InitializeRentPendingAta(task.clone()), + BaseTaskImpl::DelegateRentPendingAta(task), + ] + }) + .collect() + } + async fn fetch_commit_stage_info( intent_bundle: &ScheduledIntentBundle, task_info_fetcher: &Arc, + validator: &Pubkey, persister: &Option

, ) -> TaskBuilderResult { - // Fetch necessary data for BaseTasks creation - let rent_pending_pubkeys = intent_bundle + let all_committed_accounts = intent_bundle.get_all_committed_accounts(); + let explicit_materializations = intent_bundle .intent_bundle .rent_pending_ata_materializations .iter() - .map(|materialization| materialization.eata_pubkey) + .map(|materialization| { + (materialization.eata_pubkey, materialization.clone()) + }) + .collect::>(); + let recovered_materializations = + Self::derive_recovered_rent_pending_materializations( + &all_committed_accounts, + validator, + ); + let mut rent_pending_pubkeys = explicit_materializations + .keys() + .copied() .collect::>(); - let all_committed_accounts = intent_bundle.get_all_committed_accounts(); + rent_pending_pubkeys.extend(recovered_materializations.keys().copied()); + rent_pending_pubkeys.sort_unstable(); + rent_pending_pubkeys.dedup(); + // Get commit nonces and base accounts let min_context_slot = all_committed_accounts .iter() @@ -174,8 +270,24 @@ impl TaskBuilderImpl { min_context_slot ) ); - let commit_nonces = + let commit_nonce_result = commit_ids.map_err(TaskBuilderError::CommitTasksBuildError)?; + let mut rent_pending_ata_materializations = intent_bundle + .intent_bundle + .rent_pending_ata_materializations + .clone(); + rent_pending_ata_materializations.extend( + commit_nonce_result + .missing_metadata + .iter() + .filter(|pubkey| { + !explicit_materializations.contains_key(pubkey) + }) + .filter_map(|pubkey| { + recovered_materializations.get(pubkey).cloned() + }), + ); + let commit_nonces = commit_nonce_result.nonces; let base_accounts = base_accounts.unwrap_or_else(|err| { tracing::warn!(intent_id = intent_bundle.id, error = ?err, "Failed to fetch base accounts, falling back to CommitState"); Default::default() @@ -193,6 +305,7 @@ impl TaskBuilderImpl { Ok(CommitStageTaskInfo { commit_nonces, base_accounts, + rent_pending_ata_materializations, }) } @@ -230,6 +343,7 @@ impl TasksBuilder for TaskBuilderImpl { async fn commit_tasks( task_info_fetcher: &Arc, intent_bundle: &ScheduledIntentBundle, + validator: &Pubkey, persister: &Option

, ) -> TaskBuilderResult> { let mut tasks = Vec::new(); @@ -242,28 +356,18 @@ impl TasksBuilder for TaskBuilderImpl { let CommitStageTaskInfo { mut commit_nonces, mut base_accounts, + rent_pending_ata_materializations, } = Self::fetch_commit_stage_info( intent_bundle, task_info_fetcher, + validator, persister, ) .await?; - tasks.extend( - intent_bundle - .intent_bundle - .rent_pending_ata_materializations - .iter() - .flat_map(|materialization| { - let task = RentPendingAtaTask { - materialization: materialization.clone(), - }; - [ - BaseTaskImpl::InitializeRentPendingAta(task.clone()), - BaseTaskImpl::DelegateRentPendingAta(task), - ] - }), - ); + tasks.extend(Self::rent_pending_materialization_tasks( + rent_pending_ata_materializations, + )); // Create tasks per intent type if let Some(ref value) = intent_bundle.intent_bundle.commit { @@ -314,6 +418,7 @@ impl TasksBuilder for TaskBuilderImpl { async fn finalize_tasks( info_fetcher: &Arc, intent_bundle: &ScheduledIntentBundle, + validator: &Pubkey, ) -> TaskBuilderResult> { // Helper to create a finalize task fn finalize_task(account: &CommittedAccount) -> BaseTaskImpl { @@ -362,6 +467,7 @@ impl TasksBuilder for TaskBuilderImpl { commit_and_undelegate: &CommitAndUndelegate, info_fetcher: &Arc, rent_pending_reimbursements: &HashMap, + missing_metadata_reimbursements: &HashMap, ) -> TaskBuilderResult> { // Get rent reimbursments for undelegated accounts let accounts = commit_and_undelegate.get_committed_accounts(); @@ -375,14 +481,14 @@ impl TasksBuilder for TaskBuilderImpl { .then_some(account.pubkey) }) .collect::>(); - let fetched_rent_reimbursements = info_fetcher - .fetch_rent_reimbursements(&pubkeys, min_context_slot) + let rent_reimbursements = info_fetcher + .fetch_rent_reimbursements_with_missing_as( + &pubkeys, + min_context_slot, + missing_metadata_reimbursements, + ) .await .map_err(TaskBuilderError::FinalizedTasksBuildError)?; - let rent_reimbursements = pubkeys - .into_iter() - .zip(fetched_rent_reimbursements) - .collect::>(); let mut tasks = Vec::with_capacity(accounts.len()); for account in accounts { @@ -413,6 +519,12 @@ impl TasksBuilder for TaskBuilderImpl { } let mut tasks = Vec::new(); + let all_committed_accounts = intent_bundle.get_all_committed_accounts(); + let recovered_materializations = + TaskBuilderImpl::derive_recovered_rent_pending_materializations( + &all_committed_accounts, + validator, + ); let rent_pending_reimbursements = intent_bundle .intent_bundle .rent_pending_ata_materializations @@ -421,6 +533,15 @@ impl TasksBuilder for TaskBuilderImpl { (materialization.eata_pubkey, materialization.validator) }) .collect::>(); + let missing_metadata_reimbursements = recovered_materializations + .into_iter() + .filter(|(pubkey, _)| { + !rent_pending_reimbursements.contains_key(pubkey) + }) + .map(|(pubkey, materialization)| { + (pubkey, materialization.validator) + }) + .collect::>(); let mut futures = Vec::with_capacity(2); if let Some(ref value) = intent_bundle.intent_bundle.commit { @@ -435,6 +556,7 @@ impl TasksBuilder for TaskBuilderImpl { value, info_fetcher, &rent_pending_reimbursements, + &missing_metadata_reimbursements, )); } @@ -445,6 +567,7 @@ impl TasksBuilder for TaskBuilderImpl { value, info_fetcher, &rent_pending_reimbursements, + &missing_metadata_reimbursements, )); } @@ -634,6 +757,7 @@ mod tests { pubkeys, ) .await + .map(|result| result.nonces) } async fn fetch_next_commit_nonces_with_missing_as_zero( @@ -641,9 +765,12 @@ mod tests { pubkeys: &[Pubkey], _min_context_slot: u64, missing_metadata_as_zero: &[Pubkey], - ) -> TaskInfoFetcherResult> { + ) -> TaskInfoFetcherResult { assert_eq!(pubkeys, missing_metadata_as_zero); - Ok(pubkeys.iter().map(|pubkey| (*pubkey, 1)).collect()) + Ok(CommitNonceFetchResult { + nonces: pubkeys.iter().map(|pubkey| (*pubkey, 1)).collect(), + missing_metadata: pubkeys.iter().copied().collect(), + }) } async fn fetch_current_commit_nonces( @@ -664,6 +791,26 @@ mod tests { Ok(Vec::new()) } + async fn fetch_rent_reimbursements_with_missing_as( + &self, + pubkeys: &[Pubkey], + _min_context_slot: u64, + missing_metadata_as: &HashMap, + ) -> TaskInfoFetcherResult> { + pubkeys + .iter() + .map(|pubkey| { + missing_metadata_as + .get(pubkey) + .copied() + .map(|reimbursement| (*pubkey, reimbursement)) + .ok_or(TaskInfoFetcherError::AccountNotFoundError( + *pubkey, + )) + }) + .collect() + } + async fn get_base_accounts( &self, pubkeys: &[Pubkey], @@ -691,6 +838,7 @@ mod tests { &[], ) .await + .map(|result| result.nonces) } async fn fetch_next_commit_nonces_with_missing_as_zero( @@ -698,12 +846,14 @@ mod tests { pubkeys: &[Pubkey], _min_context_slot: u64, missing_metadata_as_zero: &[Pubkey], - ) -> TaskInfoFetcherResult> { + ) -> TaskInfoFetcherResult { assert_eq!(pubkeys, missing_metadata_as_zero); - Ok(pubkeys - .iter() - .map(|pubkey| (*pubkey, self.next_nonce)) - .collect()) + Ok(CommitNonceFetchResult::from_nonces( + pubkeys + .iter() + .map(|pubkey| (*pubkey, self.next_nonce)) + .collect(), + )) } async fn fetch_current_commit_nonces( @@ -797,6 +947,56 @@ mod tests { ) } + fn recovered_rent_pending_intent( + commit_and_undelegate: bool, + ) -> (ScheduledIntentBundle, Pubkey) { + let wallet_owner = Pubkey::new_unique(); + let mint = Pubkey::new_unique(); + let validator = Pubkey::new_unique(); + let (eata_pubkey, bump) = + try_derive_eata_address_and_bump(&wallet_owner, &mint) + .expect("eATA PDA should derive"); + let eata = EphemeralAta { + owner: wallet_owner, + mint, + amount: 7, + bump, + }; + let committed_account = CommittedAccount { + pubkey: eata_pubkey, + account: eata.into(), + remote_slot: 0, + }; + let intent_bundle = if commit_and_undelegate { + MagicIntentBundle { + commit_and_undelegate: Some(CommitAndUndelegate { + commit_action: CommitType::Standalone(vec![ + committed_account, + ]), + undelegate_action: UndelegateType::Standalone, + }), + ..Default::default() + } + } else { + MagicIntentBundle { + commit: Some(CommitType::Standalone(vec![committed_account])), + ..Default::default() + } + }; + + ( + ScheduledIntentBundle { + id: 42, + slot: 0, + blockhash: Hash::default(), + sent_transaction: Transaction::default(), + payer: Pubkey::new_unique(), + intent_bundle, + }, + validator, + ) + } + #[tokio::test] async fn rent_pending_commit_prepends_eata_materialization() { let fetcher = Arc::new(EmptyFetcher); @@ -805,6 +1005,39 @@ mod tests { let tasks = TaskBuilderImpl::commit_tasks( &fetcher, &intent, + &validator, + &None::, + ) + .await + .unwrap(); + + assert!(matches!( + tasks.as_slice(), + [ + BaseTaskImpl::InitializeRentPendingAta(_), + BaseTaskImpl::DelegateRentPendingAta(_), + BaseTaskImpl::Commit(_) + ] + )); + let delegate_ix = tasks[1].instruction(&validator); + assert_eq!(delegate_ix.program_id, EATA_PROGRAM_ID); + assert_eq!(delegate_ix.data[0], 4); + assert_eq!(&delegate_ix.data[1..], validator.as_ref()); + let BaseTaskImpl::Commit(commit) = &tasks[2] else { + panic!("expected commit task"); + }; + assert_eq!(commit.commit_id, 1); + } + + #[tokio::test] + async fn recovered_rent_pending_commit_derives_eata_materialization() { + let fetcher = Arc::new(EmptyFetcher); + let (intent, validator) = recovered_rent_pending_intent(false); + + let tasks = TaskBuilderImpl::commit_tasks( + &fetcher, + &intent, + &validator, &None::, ) .await @@ -837,6 +1070,7 @@ mod tests { let tasks = TaskBuilderImpl::commit_tasks( &fetcher, &intent, + &validator, &None::, ) .await @@ -861,9 +1095,30 @@ mod tests { let fetcher = Arc::new(EmptyFetcher); let (intent, validator) = rent_pending_intent(true); - let tasks = TaskBuilderImpl::finalize_tasks(&fetcher, &intent) - .await - .unwrap(); + let tasks = + TaskBuilderImpl::finalize_tasks(&fetcher, &intent, &validator) + .await + .unwrap(); + + assert!(matches!( + tasks.as_slice(), + [BaseTaskImpl::Finalize(_), BaseTaskImpl::Undelegate(_)] + )); + let BaseTaskImpl::Undelegate(undelegate) = &tasks[1] else { + panic!("expected undelegate task"); + }; + assert_eq!(undelegate.rent_reimbursement, validator); + } + + #[tokio::test] + async fn recovered_rent_pending_undelegation_defaults_reimbursement() { + let fetcher = Arc::new(EmptyFetcher); + let (intent, validator) = recovered_rent_pending_intent(true); + + let tasks = + TaskBuilderImpl::finalize_tasks(&fetcher, &intent, &validator) + .await + .unwrap(); assert!(matches!( tasks.as_slice(), @@ -878,11 +1133,12 @@ mod tests { #[tokio::test] async fn rent_pending_second_commit_uses_fetched_nonce() { let fetcher = Arc::new(ExistingMetadataFetcher { next_nonce: 2 }); - let (intent, _validator) = rent_pending_intent(false); + let (intent, validator) = rent_pending_intent(false); let tasks = TaskBuilderImpl::commit_tasks( &fetcher, &intent, + &validator, &None::, ) .await @@ -894,6 +1150,27 @@ mod tests { assert_eq!(commit.commit_id, 2); } + #[tokio::test] + async fn recovered_rent_pending_existing_metadata_uses_normal_commit() { + let fetcher = Arc::new(ExistingMetadataFetcher { next_nonce: 2 }); + let (intent, validator) = recovered_rent_pending_intent(false); + + let tasks = TaskBuilderImpl::commit_tasks( + &fetcher, + &intent, + &validator, + &None::, + ) + .await + .unwrap(); + + assert!(matches!(tasks.as_slice(), [BaseTaskImpl::Commit(_)])); + let BaseTaskImpl::Commit(commit) = &tasks[0] else { + panic!("expected commit task"); + }; + assert_eq!(commit.commit_id, 2); + } + #[tokio::test] async fn rent_pending_conflicting_base_delegation_is_gated_by_eata_delegate( ) { @@ -909,6 +1186,7 @@ mod tests { let tasks = TaskBuilderImpl::commit_tasks( &fetcher, &intent, + &validator, &None::, ) .await diff --git a/magicblock-committor-service/src/tasks/task_strategist.rs b/magicblock-committor-service/src/tasks/task_strategist.rs index e7f80fbd7..87efc9a8b 100644 --- a/magicblock-committor-service/src/tasks/task_strategist.rs +++ b/magicblock-committor-service/src/tasks/task_strategist.rs @@ -483,7 +483,7 @@ mod tests { use crate::{ intent_execution_manager::intent_scheduler::create_test_intent, intent_executor::task_info_fetcher::{ - TaskInfoFetcher, TaskInfoFetcherResult, + CommitNonceFetchResult, TaskInfoFetcher, TaskInfoFetcherResult, }, persist::IntentPersisterImpl, tasks::{ @@ -514,8 +514,10 @@ mod tests { pubkeys: &[Pubkey], _: u64, _: &[Pubkey], - ) -> TaskInfoFetcherResult> { - self.fetch_next_commit_nonces(pubkeys, 0).await + ) -> TaskInfoFetcherResult { + self.fetch_next_commit_nonces(pubkeys, 0) + .await + .map(CommitNonceFetchResult::from_nonces) } async fn fetch_current_commit_nonces( @@ -877,22 +879,24 @@ mod tests { let intent = create_test_intent(0, &pubkey, false); let info_fetcher = Arc::new(MockInfoFetcher); + let validator = Pubkey::new_unique(); let commit_task = TaskBuilderImpl::commit_tasks( &info_fetcher, &intent, + &validator, &None::, ) .await .unwrap(); let finalize_task = - TaskBuilderImpl::finalize_tasks(&info_fetcher, &intent) + TaskBuilderImpl::finalize_tasks(&info_fetcher, &intent, &validator) .await .unwrap(); let execution_mode = TaskStrategist::build_execution_strategy( commit_task, finalize_task, - &Pubkey::new_unique(), + &validator, &None::, ) .expect("Execution mode created"); @@ -909,22 +913,24 @@ mod tests { let intent = create_test_intent(0, &pubkeys, true); let info_fetcher = Arc::new(MockInfoFetcher); + let validator = Pubkey::new_unique(); let commit_task = TaskBuilderImpl::commit_tasks( &info_fetcher, &intent, + &validator, &None::, ) .await .unwrap(); let finalize_task = - TaskBuilderImpl::finalize_tasks(&info_fetcher, &intent) + TaskBuilderImpl::finalize_tasks(&info_fetcher, &intent, &validator) .await .unwrap(); let execution_mode = TaskStrategist::build_execution_strategy( commit_task, finalize_task, - &Pubkey::new_unique(), + &validator, &None::, ) .expect("Execution mode created"); @@ -946,22 +952,24 @@ mod tests { let intent = create_test_intent(0, &pubkeys, false); let info_fetcher = Arc::new(MockInfoFetcher); + let validator = Pubkey::new_unique(); let commit_task = TaskBuilderImpl::commit_tasks( &info_fetcher, &intent, + &validator, &None::, ) .await .unwrap(); let finalize_task = - TaskBuilderImpl::finalize_tasks(&info_fetcher, &intent) + TaskBuilderImpl::finalize_tasks(&info_fetcher, &intent, &validator) .await .unwrap(); let execution_mode = TaskStrategist::build_execution_strategy( commit_task, finalize_task, - &Pubkey::new_unique(), + &validator, &None::, ) .expect("Execution mode created"); diff --git a/magicblock-core/src/token_programs.rs b/magicblock-core/src/token_programs.rs index a9ebc7b50..c8d6c2c4c 100644 --- a/magicblock-core/src/token_programs.rs +++ b/magicblock-core/src/token_programs.rs @@ -454,6 +454,13 @@ pub fn try_get_rent_pending_ata_info( { return None; } + // Default owner/mint are reserved: they act as the legacy-decode sentinel + // for persisted MagicContext materializations. + if token_account.owner == Pubkey::default() + || token_account.mint == Pubkey::default() + { + return None; + } let expected_ata = derive_ata_with_token_program( &token_account.owner, @@ -808,5 +815,67 @@ mod tests { undelegating_account.set_undelegating(true); assert!(try_get_rent_pending_ata_info(&ata, &undelegating_account) .is_none()); + + let mut not_delegated = account.clone(); + not_delegated.set_delegated(false); + assert!(try_get_rent_pending_ata_info(&ata, ¬_delegated).is_none()); + + let mut ephemeral_account = account.clone(); + ephemeral_account.set_ephemeral(true); + assert!( + try_get_rent_pending_ata_info(&ata, &ephemeral_account).is_none() + ); + + let mut confined_account = account.clone(); + confined_account.set_confined(true); + assert!( + try_get_rent_pending_ata_info(&ata, &confined_account).is_none() + ); + + let mut wrong_owner = account.clone(); + wrong_owner.set_owner(Pubkey::new_unique()); + assert!(try_get_rent_pending_ata_info(&ata, &wrong_owner).is_none()); + + let mut native_account = account.clone(); + let mut token = SplAccount::unpack(native_account.data()).unwrap(); + token.is_native = COption::Some(0); + SplAccount::pack(token, native_account.data_as_mut_slice()).unwrap(); + assert!(try_get_rent_pending_ata_info(&ata, &native_account).is_none()); + } + + #[test] + fn rent_pending_ata_rejects_default_owner_and_mint() { + // Default owner/mint are reserved as the MagicContext legacy-decode + // sentinel and must never classify as rent-pending. + for (wallet_owner, mint) in [ + (Pubkey::default(), Pubkey::new_unique()), + (Pubkey::new_unique(), Pubkey::default()), + ] { + let ata = derive_ata(&wallet_owner, &mint); + let token_account = SplAccount { + mint, + owner: wallet_owner, + amount: 9, + delegate: COption::None, + state: AccountState::Initialized, + is_native: COption::None, + delegated_amount: 0, + close_authority: COption::Some( + RENT_PENDING_ATA_CLOSE_AUTHORITY, + ), + }; + let mut data = vec![0u8; SplAccount::LEN]; + SplAccount::pack(token_account, &mut data).unwrap(); + let mut account = AccountSharedData::from(Account { + owner: TOKEN_PROGRAM_ID, + data, + lamports: Rent::default().minimum_balance(SplAccount::LEN), + executable: false, + ..Default::default() + }); + account.set_delegated(true); + + assert!(try_get_rent_pending_ata_info(&ata, &account).is_none()); + } } } diff --git a/magicblock-processor/tests/ephemeral_accounts.rs b/magicblock-processor/tests/ephemeral_accounts.rs index 65988ee58..6386aaa0f 100644 --- a/magicblock-processor/tests/ephemeral_accounts.rs +++ b/magicblock-processor/tests/ephemeral_accounts.rs @@ -121,14 +121,13 @@ fn init_spl_mint(env: &ExecutionTestEnv, mint: Pubkey) { }; let mut data = vec![0; SplMint::LEN]; SplMint::pack(mint_state, &mut data).unwrap(); - let mut account = AccountSharedData::from(Account { + let account = AccountSharedData::from(Account { lamports: 1_000_000, data, owner: TOKEN_PROGRAM_ID, executable: false, rent_epoch: 0, }); - account.set_delegated(true); env.accountsdb.insert_account(&mint, &account).unwrap(); } diff --git a/programs/magicblock/src/ephemeral_accounts/process_create_rent_pending_ata.rs b/programs/magicblock/src/ephemeral_accounts/process_create_rent_pending_ata.rs index cbd7d98d2..341259766 100644 --- a/programs/magicblock/src/ephemeral_accounts/process_create_rent_pending_ata.rs +++ b/programs/magicblock/src/ephemeral_accounts/process_create_rent_pending_ata.rs @@ -66,6 +66,10 @@ pub(crate) fn process_create_rent_pending_ata( if mint_pubkey != mint || token_program_pubkey != token_program { return Err(InstructionError::InvalidArgument); } + // Default owner is reserved as the MagicContext legacy-decode sentinel. + if wallet_owner == Pubkey::default() { + return Err(InstructionError::InvalidArgument); + } if token_program != TOKEN_PROGRAM_ID && token_program != TOKEN_2022_PROGRAM_ID { diff --git a/programs/magicblock/src/schedule_transactions/process_schedule_commit_tests.rs b/programs/magicblock/src/schedule_transactions/process_schedule_commit_tests.rs index 67abd84ee..5ad8dfb2c 100644 --- a/programs/magicblock/src/schedule_transactions/process_schedule_commit_tests.rs +++ b/programs/magicblock/src/schedule_transactions/process_schedule_commit_tests.rs @@ -1601,6 +1601,65 @@ mod tests { .expect("payer should have been debited"); } + #[test] + #[serial] + fn test_schedule_commit_rent_pending_ata_without_fee_vault_errors() { + init_logger!(); + let payer = + Keypair::from_seed(b"rent_pending_ata_no_fee_vault_____").unwrap(); + let wallet_owner = Pubkey::new_unique(); + let mint = Pubkey::new_unique(); + let ata = derive_ata(&wallet_owner, &mint); + + // Non-delegated payer: no fee vault path, so a rent-pending ATA + // committee must be rejected instead of scheduled without a charge. + let mut account_data = { + let mut map = HashMap::new(); + map.insert( + payer.pubkey(), + AccountSharedData::new(1_000_000, 0, &system_program::id()), + ); + map.insert( + MAGIC_CONTEXT_PUBKEY, + AccountSharedData::new( + u64::MAX, + MagicContext::SIZE, + &crate::id(), + ), + ); + map.insert( + ata, + make_rent_pending_spl_ata_account(&wallet_owner, &mint, 42), + ); + map + }; + ensure_started_validator( + &mut account_data, + Some(StubNonces::Global(0)), + ); + + let mut transaction_accounts = vec![( + clock::id(), + create_account_shared_data_for_test(&get_clock()), + )]; + let ix = InstructionUtils::schedule_commit_instruction( + &payer.pubkey(), + vec![ata], + ); + extend_transaction_accounts_from_ix( + &ix, + &mut account_data, + &mut transaction_accounts, + ); + + process_instruction( + ix.data.as_slice(), + transaction_accounts, + ix.accounts, + Err(InstructionError::IllegalOwner), + ); + } + #[test] #[serial] fn test_schedule_commit_rent_pending_ata_records_materialization() { diff --git a/programs/magicblock/src/schedule_transactions/process_schedule_intent_bundle.rs b/programs/magicblock/src/schedule_transactions/process_schedule_intent_bundle.rs index e60338b69..d261b8477 100644 --- a/programs/magicblock/src/schedule_transactions/process_schedule_intent_bundle.rs +++ b/programs/magicblock/src/schedule_transactions/process_schedule_intent_bundle.rs @@ -159,12 +159,16 @@ pub(crate) fn process_schedule_intent_bundle( let transaction_context = &*invoke_context.transaction_context; let payer_account = get_instruction_account_with_idx(transaction_context, PAYER_IDX)?; - let magic_fee_vault = try_get_fee_vault( - transaction_context, - invoke_context, - PAYER_IDX, - MAGIC_CONTEXT_IDX + 1, - )?; + // Reuse the pre-construction fee-vault decision: undelegation may have + // cleared the payer's delegated flag, which must not skip the charge. + let magic_fee_vault = rent_pending_materialization_accounts + .map(|_| { + get_instruction_account_with_idx( + transaction_context, + MAGIC_CONTEXT_IDX + 1, + ) + }) + .transpose()?; if let Some(magic_fee_vault) = magic_fee_vault { let rent_pending_pubkeys = scheduled_intent .intent_bundle From c07777a30fb02e496e042657b03a94fd6232e9cc Mon Sep 17 00:00:00 2001 From: Gabriele Picco Date: Fri, 3 Jul 2026 22:45:25 +0800 Subject: [PATCH 05/16] Fix committor integration test build --- .../test-committor-service/tests/common.rs | 11 +++++++---- .../tests/test_intent_executor.rs | 6 ++++-- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/test-integration/test-committor-service/tests/common.rs b/test-integration/test-committor-service/tests/common.rs index acffcfe81..33e3a6fc8 100644 --- a/test-integration/test-committor-service/tests/common.rs +++ b/test-integration/test-committor-service/tests/common.rs @@ -10,8 +10,8 @@ use async_trait::async_trait; use magicblock_committor_service::{ intent_executor::{ task_info_fetcher::{ - CacheTaskInfoFetcher, TaskInfoFetcher, TaskInfoFetcherError, - TaskInfoFetcherResult, + CacheTaskInfoFetcher, CommitNonceFetchResult, TaskInfoFetcher, + TaskInfoFetcherError, TaskInfoFetcherResult, }, IntentExecutorImpl, }, @@ -181,8 +181,11 @@ impl TaskInfoFetcher for MockTaskInfoFetcher { pubkeys: &[Pubkey], _: u64, _: &[Pubkey], - ) -> TaskInfoFetcherResult> { - self.fetch_next_commit_nonces(pubkeys, 0).await + ) -> TaskInfoFetcherResult { + Ok(CommitNonceFetchResult { + nonces: self.fetch_next_commit_nonces(pubkeys, 0).await?, + missing_metadata: Default::default(), + }) } async fn fetch_current_commit_nonces( diff --git a/test-integration/test-committor-service/tests/test_intent_executor.rs b/test-integration/test-committor-service/tests/test_intent_executor.rs index 26acbc58a..325d73769 100644 --- a/test-integration/test-committor-service/tests/test_intent_executor.rs +++ b/test-integration/test-committor-service/tests/test_intent_executor.rs @@ -1427,12 +1427,13 @@ async fn create_two_stage_executor<'a>( let commit_tasks = TaskBuilderImpl::commit_tasks( task_info_fetcher, intent, + authority, &None::, ) .await .unwrap(); let finalize_tasks = - TaskBuilderImpl::finalize_tasks(task_info_fetcher, intent) + TaskBuilderImpl::finalize_tasks(task_info_fetcher, intent, authority) .await .unwrap(); let commit_strategy = TaskStrategist::build_strategy( @@ -1696,12 +1697,13 @@ async fn single_flow_transaction_strategy( let mut tasks = TaskBuilderImpl::commit_tasks( task_info_fetcher, intent, + authority, &None::, ) .await .unwrap(); let finalize_tasks = - TaskBuilderImpl::finalize_tasks(task_info_fetcher, intent) + TaskBuilderImpl::finalize_tasks(task_info_fetcher, intent, authority) .await .unwrap(); tasks.extend(finalize_tasks); From ec351fb7abebed9a66159c98aa02d13e0cc950d1 Mon Sep 17 00:00:00 2001 From: Gabriele Picco Date: Fri, 3 Jul 2026 23:44:29 +0800 Subject: [PATCH 06/16] Address rent-pending ATA review feedback --- .agents/specs/validator-specification.md | 8 ++ .../process_create_rent_pending_ata.rs | 84 ++++++++++++++++- .../process_schedule_commit.rs | 43 +++++---- .../process_schedule_intent_bundle.rs | 92 ++++++++++--------- 4 files changed, 161 insertions(+), 66 deletions(-) diff --git a/.agents/specs/validator-specification.md b/.agents/specs/validator-specification.md index 922dce415..f9cc75ebd 100644 --- a/.agents/specs/validator-specification.md +++ b/.agents/specs/validator-specification.md @@ -356,6 +356,14 @@ already delegated to another validator, the e-token delegation instruction is the expected validator-mismatch failure gate; the DLP commit/undelegation must not be treated as successful. +For commit-and-undelegate scheduling paths, fee-vault charging and commit-limit +checks must happen before local accounts are marked undelegating, because the +local mutation clears the `delegated` flag and the payer can also be one of the +undelegated accounts. Token-2022 rent-pending ATA initialization must mirror the +mint's default account state extension; a mint whose `DefaultAccountState` is +`Frozen` must create a frozen local rent-pending ATA rather than an initialized +one. + ## RPC and router specification The MagicBlock Router API implements most standard Solana JSON-RPC methods and adds MagicBlock-specific methods. diff --git a/programs/magicblock/src/ephemeral_accounts/process_create_rent_pending_ata.rs b/programs/magicblock/src/ephemeral_accounts/process_create_rent_pending_ata.rs index 341259766..5008571fa 100644 --- a/programs/magicblock/src/ephemeral_accounts/process_create_rent_pending_ata.rs +++ b/programs/magicblock/src/ephemeral_accounts/process_create_rent_pending_ata.rs @@ -19,8 +19,9 @@ use spl_token::state::{ }; use spl_token_2022::{ extension::{ - BaseStateWithExtensions, BaseStateWithExtensionsMut, ExtensionType, - StateWithExtensions, StateWithExtensionsMut, + default_account_state::DefaultAccountState, BaseStateWithExtensions, + BaseStateWithExtensionsMut, ExtensionType, StateWithExtensions, + StateWithExtensionsMut, }, state::{ Account as Token2022Account, AccountState as Token2022AccountState, @@ -40,6 +41,7 @@ const TOKEN_PROGRAM_IDX: u16 = 3; struct TokenAccountShape { len: usize, required_extensions: Vec, + initial_state: Token2022AccountState, } pub(crate) fn process_create_rent_pending_ata( @@ -125,6 +127,7 @@ pub(crate) fn process_create_rent_pending_ata( wallet_owner, mint, &token_account_shape.required_extensions, + token_account_shape.initial_state, )?; acc.set_remote_slot(0); acc.set_delegated(true); @@ -168,6 +171,7 @@ fn token_account_shape( Ok(TokenAccountShape { len: SplAccount::LEN, required_extensions: Vec::new(), + initial_state: Token2022AccountState::Initialized, }) } else { let mint = StateWithExtensions::::unpack(mint_data) @@ -175,6 +179,16 @@ fn token_account_shape( let mint_extensions = mint .get_extension_types() .map_err(|_| InstructionError::InvalidAccountData)?; + let initial_state = + if mint_extensions.contains(&ExtensionType::DefaultAccountState) { + let default_state = mint + .get_extension::() + .map_err(|_| InstructionError::InvalidAccountData)?; + Token2022AccountState::try_from(default_state.state) + .map_err(|_| InstructionError::InvalidAccountData)? + } else { + Token2022AccountState::Initialized + }; let required_extensions = ExtensionType::get_required_init_account_extensions( &mint_extensions, @@ -186,6 +200,7 @@ fn token_account_shape( Ok(TokenAccountShape { len, required_extensions, + initial_state, }) } } @@ -196,6 +211,7 @@ fn initialize_token_data( wallet_owner: Pubkey, mint: Pubkey, required_extensions: &[ExtensionType], + initial_state: Token2022AccountState, ) -> Result<(), InstructionError> { if *token_program == TOKEN_PROGRAM_ID { let account = SplAccount { @@ -226,7 +242,7 @@ fn initialize_token_data( owner: wallet_owner, amount: 0, delegate: COption::None, - state: Token2022AccountState::Initialized, + state: initial_state, is_native: COption::None, delegated_amount: 0, close_authority: COption::Some(RENT_PENDING_ATA_CLOSE_AUTHORITY), @@ -311,6 +327,12 @@ mod tests { .unwrap(); for extension_type in extension_types { match extension_type { + ExtensionType::DefaultAccountState => { + let extension = state + .init_extension::(false) + .unwrap(); + extension.state = Token2022AccountState::Frozen.into(); + } ExtensionType::NonTransferable => { state.init_extension::(false).unwrap(); } @@ -527,4 +549,60 @@ mod tests { ); assert!(try_get_rent_pending_ata_info(&ata, ata_after).is_some()); } + + #[test] + fn create_rent_pending_token_2022_ata_honors_default_account_state() { + let payer = Pubkey::new_unique(); + let wallet_owner = Pubkey::new_unique(); + let mint = Pubkey::new_unique(); + let ata = derive_ata_with_token_program( + &wallet_owner, + &mint, + &TOKEN_2022_PROGRAM_ID, + ); + + let ix = Instruction::new_with_bincode( + crate::id(), + &MagicBlockInstruction::CreateRentPendingAta { + wallet_owner, + mint, + token_program: TOKEN_2022_PROGRAM_ID, + }, + vec![ + AccountMeta::new(payer, true), + AccountMeta::new(ata, false), + AccountMeta::new_readonly(mint, false), + AccountMeta::new_readonly(TOKEN_2022_PROGRAM_ID, false), + ], + ); + let accounts = process_instruction( + &ix.data, + vec![ + ( + payer, + AccountSharedData::new(1_000_000, 0, &system_program::id()), + ), + (ata, AccountSharedData::new(0, 0, &system_program::id())), + ( + mint, + token_2022_mint_account(&[ + ExtensionType::DefaultAccountState, + ]), + ), + ( + TOKEN_2022_PROGRAM_ID, + AccountSharedData::new(1, 0, &native_loader::id()), + ), + ], + ix.accounts, + Ok(()), + ); + + let ata_after = &accounts[1]; + let token_account = + StateWithExtensions::::unpack(ata_after.data()) + .unwrap(); + assert_eq!(token_account.base.state, Token2022AccountState::Frozen); + assert!(try_get_rent_pending_ata_info(&ata, ata_after).is_some()); + } } diff --git a/programs/magicblock/src/schedule_transactions/process_schedule_commit.rs b/programs/magicblock/src/schedule_transactions/process_schedule_commit.rs index b26f5f57e..b05da78c8 100644 --- a/programs/magicblock/src/schedule_transactions/process_schedule_commit.rs +++ b/programs/magicblock/src/schedule_transactions/process_schedule_commit.rs @@ -152,6 +152,7 @@ pub(crate) fn process_schedule_commit( // Thus we can be `invoke`d unsigned and no seeds need to be provided let mut committed_accounts: Vec = Vec::new(); let mut rent_pending_ata_materializations = Vec::new(); + let mut undelegated_accounts_ref = Vec::new(); let mut seen_committed_pubkeys: HashSet = HashSet::new(); for idx in committees_start..ix_accs_len { let acc_pubkey = @@ -263,25 +264,7 @@ pub(crate) fn process_schedule_commit( } if opts.request_undelegation { - // If the account is scheduled to be undelegated then we need to lock it - // immediately in order to prevent the following actions: - // - writes to the account - // - scheduling further commits for this account - // - // Setting the owner will prevent both, since in both cases the _actual_ - // owner program needs to sign for the account which is not possible at - // that point - // NOTE: this owner change only takes effect if the transaction which - // includes this instruction succeeds. - // - // We also set the undelegating flag on the account in order to detect - // undelegations for which we miss updates - mark_account_as_undelegated(&acc)?; - ic_msg!( - invoke_context, - "ScheduleCommit: Marking account {} as undelegating", - acc_pubkey - ); + undelegated_accounts_ref.push((*acc_pubkey, acc)); } } @@ -311,6 +294,28 @@ pub(crate) fn process_schedule_commit( check_commit_limits(&committed_accounts, invoke_context)?; } + for (acc_pubkey, acc) in undelegated_accounts_ref.iter() { + // If the account is scheduled to be undelegated then we need to lock it + // immediately in order to prevent the following actions: + // - writes to the account + // - scheduling further commits for this account + // + // Setting the owner will prevent both, since in both cases the _actual_ + // owner program needs to sign for the account which is not possible at + // that point + // NOTE: this owner change only takes effect if the transaction which + // includes this instruction succeeds. + // + // We also set the undelegating flag on the account in order to detect + // undelegations for which we miss updates + mark_account_as_undelegated(acc)?; + ic_msg!( + invoke_context, + "ScheduleCommit: Marking account {} as undelegating", + acc_pubkey + ); + } + // NOTE: this is only protected by all the above checks however if the // instruction fails for other reasons detected afterward then the commit // stays scheduled diff --git a/programs/magicblock/src/schedule_transactions/process_schedule_intent_bundle.rs b/programs/magicblock/src/schedule_transactions/process_schedule_intent_bundle.rs index d261b8477..e41d4aacd 100644 --- a/programs/magicblock/src/schedule_transactions/process_schedule_intent_bundle.rs +++ b/programs/magicblock/src/schedule_transactions/process_schedule_intent_bundle.rs @@ -137,6 +137,54 @@ pub(crate) fn process_schedule_intent_bundle( &construction_context, )?; + if rent_pending_materialization_accounts.is_some() { + let transaction_context = + construction_context.transaction_context(); + let payer_account = get_instruction_account_with_idx( + transaction_context, + PAYER_IDX, + )?; + let magic_fee_vault = get_instruction_account_with_idx( + transaction_context, + MAGIC_CONTEXT_IDX + 1, + )?; + let rent_pending_pubkeys = scheduled_intent + .intent_bundle + .rent_pending_ata_materializations + .iter() + .map(|materialization| materialization.eata_pubkey) + .collect::>(); + let chargable_accounts = scheduled_intent + .get_all_committed_accounts() + .into_iter() + .filter(|account| { + !rent_pending_pubkeys.contains(&account.pubkey) + }) + .collect::>(); + let mut nonces = fetch_current_commit_nonces(&chargable_accounts)?; + for pubkey in rent_pending_pubkeys { + nonces.insert(pubkey, 0); + } + let fee = scheduled_intent + .calculate_fee(&nonces)? + .checked_add(calculate_rent_pending_ata_materialization_fee( + scheduled_intent + .intent_bundle + .rent_pending_ata_materializations + .len(), + )?) + .ok_or(InstructionError::ArithmeticOverflow)?; + // Charge before local undelegation clears a self-payer's delegated bit. + charge_delegated_payer(&payer_account, &magic_fee_vault, fee)?; + } else if let Some(commit_accounts) = + scheduled_intent.get_commit_intent_accounts() + { + check_commit_limits( + commit_accounts, + construction_context.invoke_context, + )?; + } + let mut undelegated_pubkeys = Vec::with_capacity(undelegated_accounts_ref.len()); // Change owner to dlp and set undelegating flag. @@ -157,50 +205,6 @@ pub(crate) fn process_schedule_intent_bundle( } let transaction_context = &*invoke_context.transaction_context; - let payer_account = - get_instruction_account_with_idx(transaction_context, PAYER_IDX)?; - // Reuse the pre-construction fee-vault decision: undelegation may have - // cleared the payer's delegated flag, which must not skip the charge. - let magic_fee_vault = rent_pending_materialization_accounts - .map(|_| { - get_instruction_account_with_idx( - transaction_context, - MAGIC_CONTEXT_IDX + 1, - ) - }) - .transpose()?; - if let Some(magic_fee_vault) = magic_fee_vault { - let rent_pending_pubkeys = scheduled_intent - .intent_bundle - .rent_pending_ata_materializations - .iter() - .map(|materialization| materialization.eata_pubkey) - .collect::>(); - let chargable_accounts = scheduled_intent - .get_all_committed_accounts() - .into_iter() - .filter(|account| !rent_pending_pubkeys.contains(&account.pubkey)) - .collect::>(); - let mut nonces = fetch_current_commit_nonces(&chargable_accounts)?; - for pubkey in rent_pending_pubkeys { - nonces.insert(pubkey, 0); - } - let fee = scheduled_intent - .calculate_fee(&nonces)? - .checked_add(calculate_rent_pending_ata_materialization_fee( - scheduled_intent - .intent_bundle - .rent_pending_ata_materializations - .len(), - )?) - .ok_or(InstructionError::ArithmeticOverflow)?; - charge_delegated_payer(&payer_account, &magic_fee_vault, fee)?; - } else if let Some(commit_accounts) = - scheduled_intent.get_commit_intent_accounts() - { - check_commit_limits(commit_accounts, invoke_context)?; - } - let action_sent_signature = scheduled_intent.sent_transaction.signatures[0]; context.add_scheduled_action(scheduled_intent); From 5cdff3fb8031a5faff338aa82ae06894bf845f65 Mon Sep 17 00:00:00 2001 From: Gabriele Picco Date: Sat, 4 Jul 2026 00:09:11 +0800 Subject: [PATCH 07/16] Honor existing rent-pending commit nonces --- .agents/specs/validator-specification.md | 4 +- .../src/schedule_transactions/mod.rs | 65 ++++++++++++++++++- .../process_schedule_commit.rs | 17 ++--- .../process_schedule_commit_tests.rs | 55 ++++++++++++++++ .../process_schedule_intent_bundle.rs | 21 +++--- 5 files changed, 136 insertions(+), 26 deletions(-) diff --git a/.agents/specs/validator-specification.md b/.agents/specs/validator-specification.md index f9cc75ebd..404e155c6 100644 --- a/.agents/specs/validator-specification.md +++ b/.agents/specs/validator-specification.md @@ -362,7 +362,9 @@ local mutation clears the `delegated` flag and the payer can also be one of the undelegated accounts. Token-2022 rent-pending ATA initialization must mirror the mint's default account state extension; a mint whose `DefaultAccountState` is `Frozen` must create a frozen local rent-pending ATA rather than an initialized -one. +one. Rent-pending eATA fee calculation must use an existing base commit nonce +when delegation metadata already exists, and default the nonce to zero only when +that metadata is actually missing. ## RPC and router specification diff --git a/programs/magicblock/src/schedule_transactions/mod.rs b/programs/magicblock/src/schedule_transactions/mod.rs index b16b66a5a..943a22713 100644 --- a/programs/magicblock/src/schedule_transactions/mod.rs +++ b/programs/magicblock/src/schedule_transactions/mod.rs @@ -9,7 +9,10 @@ mod process_schedule_intent_bundle; mod process_scheduled_commit_sent; pub(crate) mod transaction_scheduler; -use std::sync::Arc; +use std::{ + collections::{HashMap, HashSet}, + sync::Arc, +}; use magicblock_core::intent::CommittedAccount; use magicblock_magic_program_api::{ @@ -154,6 +157,66 @@ pub(crate) fn check_commit_limits( } } +pub(crate) fn fetch_commit_nonces_with_missing_as_zero( + commits: &[CommittedAccount], + missing_as_zero: &HashSet, +) -> Result, InstructionError> { + match fetch_current_commit_nonces(commits) { + Ok(mut nonces) => { + fill_missing_commit_nonces(&mut nonces, commits, missing_as_zero)?; + Ok(nonces) + } + Err(InstructionError::Custom(MISSING_COMMIT_NONCE_ERR)) => { + fetch_commit_nonces_individually(commits, missing_as_zero) + } + Err(err) => Err(err), + } +} + +fn fetch_commit_nonces_individually( + commits: &[CommittedAccount], + missing_as_zero: &HashSet, +) -> Result, InstructionError> { + let mut nonces = HashMap::with_capacity(commits.len()); + for account in commits { + match fetch_current_commit_nonces(std::slice::from_ref(account)) { + Ok(mut fetched) => { + fill_missing_commit_nonces( + &mut fetched, + std::slice::from_ref(account), + missing_as_zero, + )?; + nonces.extend(fetched); + } + Err(InstructionError::Custom(MISSING_COMMIT_NONCE_ERR)) + if missing_as_zero.contains(&account.pubkey) => + { + nonces.insert(account.pubkey, 0); + } + Err(err) => return Err(err), + } + } + Ok(nonces) +} + +fn fill_missing_commit_nonces( + nonces: &mut HashMap, + commits: &[CommittedAccount], + missing_as_zero: &HashSet, +) -> Result<(), InstructionError> { + for account in commits { + if nonces.contains_key(&account.pubkey) { + continue; + } + if missing_as_zero.contains(&account.pubkey) { + nonces.insert(account.pubkey, 0); + } else { + return Err(InstructionError::Custom(MISSING_COMMIT_NONCE_ERR)); + } + } + Ok(()) +} + pub(crate) fn magic_fee_vault_pubkey() -> Pubkey { let validator_authority = crate::validator::validator_authority_id(); Pubkey::find_program_address( diff --git a/programs/magicblock/src/schedule_transactions/process_schedule_commit.rs b/programs/magicblock/src/schedule_transactions/process_schedule_commit.rs index b05da78c8..065312376 100644 --- a/programs/magicblock/src/schedule_transactions/process_schedule_commit.rs +++ b/programs/magicblock/src/schedule_transactions/process_schedule_commit.rs @@ -20,9 +20,9 @@ use crate::{ MagicBaseIntent, MagicIntentBundle, ScheduledIntentBundle, UndelegateType, }, - magic_sys::fetch_current_commit_nonces, schedule_transactions::{ - self, check_commit_limits, magic_fee_vault_pubkey, try_get_fee_vault, + self, check_commit_limits, fetch_commit_nonces_with_missing_as_zero, + magic_fee_vault_pubkey, try_get_fee_vault, }, utils::{ account_actions::{ @@ -273,15 +273,10 @@ pub(crate) fn process_schedule_commit( .iter() .map(|materialization| materialization.eata_pubkey) .collect::>(); - let nonce_accounts = committed_accounts - .iter() - .filter(|account| !rent_pending_pubkeys.contains(&account.pubkey)) - .cloned() - .collect::>(); - let mut nonces = fetch_current_commit_nonces(&nonce_accounts)?; - for pubkey in rent_pending_pubkeys { - nonces.insert(pubkey, 0); - } + let nonces = fetch_commit_nonces_with_missing_as_zero( + &committed_accounts, + &rent_pending_pubkeys, + )?; let fee = calculate_commit_fee(&committed_accounts, &nonces)? .checked_add(calculate_rent_pending_ata_materialization_fee( rent_pending_ata_materializations.len(), diff --git a/programs/magicblock/src/schedule_transactions/process_schedule_commit_tests.rs b/programs/magicblock/src/schedule_transactions/process_schedule_commit_tests.rs index 5ad8dfb2c..ba1fa07a2 100644 --- a/programs/magicblock/src/schedule_transactions/process_schedule_commit_tests.rs +++ b/programs/magicblock/src/schedule_transactions/process_schedule_commit_tests.rs @@ -1741,6 +1741,61 @@ mod tests { assert_eq!(materialization.delegated_vault, magic_fee_vault_pubkey()); } + #[test] + #[serial] + fn test_schedule_commit_rent_pending_ata_uses_existing_nonce_fee() { + init_logger!(); + let payer = + Keypair::from_seed(b"rent_pending_ata_existing_nonce_").unwrap(); + let wallet_owner = Pubkey::new_unique(); + let mint = Pubkey::new_unique(); + let ata = derive_ata(&wallet_owner, &mint); + let eata = derive_eata(&wallet_owner, &mint); + let expected_fee = + COMMIT_FEE_LAMPORTS + RENT_PENDING_ATA_MATERIALIZATION_FEE_LAMPORTS; + + let mut per_account = HashMap::new(); + per_account.insert(eata, ACTUAL_COMMIT_LIMIT); + let (mut account_data, mut transaction_accounts) = + prepare_delegated_payer_transaction( + &payer, + TOKEN_PROGRAM_ID, + &[ata], + StubNonces::PerAccount(per_account), + ); + account_data.insert( + ata, + make_rent_pending_spl_ata_account(&wallet_owner, &mint, 42), + ); + + let ix = + InstructionUtils::schedule_commit_with_delegated_payer_instruction( + &payer.pubkey(), + vec![ata], + ); + extend_transaction_accounts_from_ix( + &ix, + &mut account_data, + &mut transaction_accounts, + ); + + let processed_scheduled = process_instruction( + ix.data.as_slice(), + transaction_accounts, + ix.accounts, + Ok(()), + ); + + processed_scheduled + .iter() + .find(|a| a.lamports() == expected_fee && a.delegated()) + .expect("fee vault should receive materialization and commit fees"); + processed_scheduled + .iter() + .find(|a| a.lamports() == 1_000_000 - expected_fee && a.delegated()) + .expect("payer should be charged materialization and commit fees"); + } + #[test] #[serial] fn test_schedule_commit_delegated_payer_only_charges_above_limit() { diff --git a/programs/magicblock/src/schedule_transactions/process_schedule_intent_bundle.rs b/programs/magicblock/src/schedule_transactions/process_schedule_intent_bundle.rs index e41d4aacd..9411beb82 100644 --- a/programs/magicblock/src/schedule_transactions/process_schedule_intent_bundle.rs +++ b/programs/magicblock/src/schedule_transactions/process_schedule_intent_bundle.rs @@ -12,9 +12,9 @@ use crate::{ calculate_rent_pending_ata_materialization_fee, CommitType, ConstructionContext, ScheduledIntentBundle, }, - magic_sys::fetch_current_commit_nonces, schedule_transactions::{ - check_commit_limits, check_magic_context_id, get_clock, + check_commit_limits, check_magic_context_id, + fetch_commit_nonces_with_missing_as_zero, get_clock, get_parent_program_id, try_get_fee_vault, MAGIC_CONTEXT_IDX, PAYER_IDX, }, utils::{ @@ -154,17 +154,12 @@ pub(crate) fn process_schedule_intent_bundle( .iter() .map(|materialization| materialization.eata_pubkey) .collect::>(); - let chargable_accounts = scheduled_intent - .get_all_committed_accounts() - .into_iter() - .filter(|account| { - !rent_pending_pubkeys.contains(&account.pubkey) - }) - .collect::>(); - let mut nonces = fetch_current_commit_nonces(&chargable_accounts)?; - for pubkey in rent_pending_pubkeys { - nonces.insert(pubkey, 0); - } + let committed_accounts = + scheduled_intent.get_all_committed_accounts(); + let nonces = fetch_commit_nonces_with_missing_as_zero( + &committed_accounts, + &rent_pending_pubkeys, + )?; let fee = scheduled_intent .calculate_fee(&nonces)? .checked_add(calculate_rent_pending_ata_materialization_fee( From 6f515d7d0f4fd8d26e501358536e6955efa99d73 Mon Sep 17 00:00:00 2001 From: Gabriele Picco Date: Sat, 4 Jul 2026 12:24:20 +0800 Subject: [PATCH 08/16] Address rent-pending review follow-ups --- .../crates/magicblock-committor-service.md | 19 +++-- Cargo.toml | 4 +- docs/mimd-rent-pending-ephemeral-ata.md | 4 +- .../src/intent_executor/task_info_fetcher.rs | 82 ++++++++++++++----- 4 files changed, 78 insertions(+), 31 deletions(-) diff --git a/.agents/context/crates/magicblock-committor-service.md b/.agents/context/crates/magicblock-committor-service.md index 012e8b6e3..cc8b650a5 100644 --- a/.agents/context/crates/magicblock-committor-service.md +++ b/.agents/context/crates/magicblock-committor-service.md @@ -207,12 +207,12 @@ For committed accounts with `data.len() > COMMIT_STATE_SIZE_THRESHOLD` (`256`), Rent-pending ATA materialization metadata is handled before normal DLP commit tasks. For each metadata entry, `TaskBuilderImpl::commit_tasks` prepends an -e-token initialize-eATA task and a delegate-eATA-to-this-validator task, skips -base nonce/base-account fetches for the newly materialized eATA, and uses -synthetic commit nonce `1` for the following DLP commit. If an eATA already -exists and is delegated to another validator, the e-token delegate task is the -expected validator-mismatch failure gate before any DLP commit/undelegation -task is allowed to succeed. +e-token initialize-eATA task and a delegate-eATA-to-this-validator task. The +task builder still fetches the eATA commit nonce; it defaults missing base DLP +metadata to current nonce `0` only for rent-pending eATAs, otherwise it uses the +fetched base nonce. If an eATA already exists and is delegated to another +validator, the e-token delegate task is the expected validator-mismatch failure +gate before any DLP commit/undelegation task is allowed to succeed. ### Delivery preparation and cleanup flow @@ -234,6 +234,13 @@ Cleanup closes prepared buffers and releases TableMania pubkeys. `IntentExecutio `IntentExecutorImpl` resets cached nonces according to execution certainty. On any execution error, it resets all committed pubkeys because it cannot know what landed on chain. On successful undelegation paths, it resets only the pubkeys returned by `get_undelegate_intent_pubkeys()` and `get_commit_finalize_and_undelegate_intent_pubkeys()`. Other successfully committed pubkeys keep their incremented cached nonce, which avoids a chain re-fetch racing the just-landed finalize and reusing a stale nonce/buffer PDA. +When `fetch_next_commit_nonces_with_missing_as_zero` defaults a rent-pending +eATA with missing base metadata to zero, the cache records that the nonce came +from missing metadata. The next fetch for that pubkey must re-check the inner +fetcher instead of treating the entry as a normal cache hit, so a successfully +materialized base metadata account clears the recovery marker and does not +generate duplicate init/delegate materialization tasks. + Do not remove sorted lock acquisition or the retiring map without replacing the deadlock/race prevention. Commit nonce races can cause base-layer commit failures and stuck undelegations. ### `min_context_slot` and freshness diff --git a/Cargo.toml b/Cargo.toml index 451715d7d..f1d15daf4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -146,7 +146,7 @@ solana-transaction-context = { git = "https://github.com/magicblock-labs/magicbl ] } solana-transaction-error = { version = "3.0" } solana-transaction-status = { version = "4.0" } -solana-transaction-status-client-types = "4.0" +solana-transaction-status-client-types = "=4.0.0" solana-zk-elgamal-proof-program = { version = "=4.0.0", features = [ "agave-unstable-api", ] } @@ -195,7 +195,7 @@ serde_with = "3.16" serial_test = "3.2" sha3 = "0.10.8" solana-account = { git = "https://github.com/magicblock-labs/magicblock-svm.git", rev = "190146af2ac0890848b50e8fc9d6c926c8205b5e" } -solana-account-decoder = { version = "4.0" } +solana-account-decoder = { version = "=4.0.0" } solana-account-decoder-client-types = { version = "4.0" } solana-account-info = { version = "3.1" } solana-address-lookup-table-interface = { version = "3.0" } diff --git a/docs/mimd-rent-pending-ephemeral-ata.md b/docs/mimd-rent-pending-ephemeral-ata.md index 4461a6beb..11edf21ed 100644 --- a/docs/mimd-rent-pending-ephemeral-ata.md +++ b/docs/mimd-rent-pending-ephemeral-ata.md @@ -169,7 +169,9 @@ The processor must: - `mint = mint`; - `owner = wallet_owner`; - `amount = 0`; - - token account state is initialized; + - token account state is initialized for SPL Token, and for Token-2022 + mirrors the mint `DefaultAccountState` extension (`Initialized` by + default, `Frozen` when the mint default is frozen); - delegate is unset; - native state is unset; - close authority is `Some(RENT_PENDING_ATA_CLOSE_AUTHORITY)`. diff --git a/magicblock-committor-service/src/intent_executor/task_info_fetcher.rs b/magicblock-committor-service/src/intent_executor/task_info_fetcher.rs index a2138dd04..05b1547f9 100644 --- a/magicblock-committor-service/src/intent_executor/task_info_fetcher.rs +++ b/magicblock-committor-service/src/intent_executor/task_info_fetcher.rs @@ -882,32 +882,25 @@ impl TaskInfoFetcher for CacheTaskInfoFetcher { let nonce_guards = locks_guard.lock().await; let (mut existing, mut missing) = (vec![], vec![]); for (pubkey, guard) in nonce_guards { - if guard.value.is_some() { + if guard.value.is_some() && !guard.missing_metadata { existing.push((pubkey, guard)); } else { missing.push((pubkey, guard)) } } - let zero_defaults: HashSet<_> = - missing_metadata_as_zero.iter().copied().collect(); // If all in cache - great! return if missing.is_empty() { let mut nonces = HashMap::with_capacity(existing.len()); - let mut missing_metadata = HashSet::new(); for (pubkey, mut guard) in existing { if let Some(nonce) = guard.value.as_mut() { *nonce += 1; nonces.insert(*pubkey, *nonce); - if guard.missing_metadata && zero_defaults.contains(pubkey) - { - missing_metadata.insert(*pubkey); - } } } return Ok(CommitNonceFetchResult { nonces, - missing_metadata, + missing_metadata: HashSet::new(), }); } @@ -931,9 +924,6 @@ impl TaskInfoFetcher for CacheTaskInfoFetcher { if let Some(nonce) = guard.value.as_mut() { *nonce += 1; nonces.insert(*pubkey, *nonce); - if guard.missing_metadata && zero_defaults.contains(pubkey) { - missing_metadata.insert(*pubkey); - } } } for (pubkey, mut guard) in missing { @@ -1323,6 +1313,35 @@ mod tests { assert_eq!(r1[&pk1], 51); // re-fetched (50 + 1) } + #[tokio::test] + async fn missing_metadata_cache_entry_refetches_until_materialized() { + let pk = Pubkey::new_unique(); + let fetcher = FetcherBuilder::new(vec![0, 20]) + .missing_metadata(vec![true, false]) + .build(); + + let r1 = fetcher + .fetch_next_commit_nonces_with_missing_as_zero(&[pk], 0, &[pk]) + .await + .unwrap(); + assert_eq!(r1.nonces[&pk], 1); + assert!(r1.missing_metadata.contains(&pk)); + + let r2 = fetcher + .fetch_next_commit_nonces_with_missing_as_zero(&[pk], 0, &[pk]) + .await + .unwrap(); + assert_eq!(r2.nonces[&pk], 21); + assert!(r2.missing_metadata.is_empty()); + + let r3 = fetcher + .fetch_next_commit_nonces_with_missing_as_zero(&[pk], 0, &[pk]) + .await + .unwrap(); + assert_eq!(r3.nonces[&pk], 22); + assert!(r3.missing_metadata.is_empty()); + } + #[tokio::test] async fn peek_awaits_inflight_fetch() { let pk1 = Pubkey::new_unique(); @@ -1372,6 +1391,7 @@ mod tests { /// Fetcher mock struct MockInfoFetcher { nonces: Mutex>, + missing_metadata: Mutex>, delay: Option, } @@ -1379,10 +1399,19 @@ mod tests { fn new(nonces: Vec) -> Self { Self { nonces: Mutex::new(nonces.into()), + missing_metadata: Mutex::new(VecDeque::new()), delay: None, } } + fn with_missing_metadata( + mut self, + missing_metadata: Vec, + ) -> Self { + self.missing_metadata = Mutex::new(missing_metadata.into()); + self + } + fn with_delay(mut self, delay: Duration) -> Self { self.delay = Some(delay); self @@ -1415,16 +1444,20 @@ mod tests { tokio::time::sleep(delay).await; } let mut q = self.nonces.lock().unwrap(); - Ok(CommitNonceFetchResult::from_nonces( - pubkeys - .iter() - .map(|pk| { - let nonce = - q.pop_front().expect("mock nonce queue exhausted"); - (*pk, nonce + 1) - }) - .collect(), - )) + let mut missing_q = self.missing_metadata.lock().unwrap(); + let mut nonces = HashMap::with_capacity(pubkeys.len()); + let mut missing_metadata = HashSet::new(); + for pk in pubkeys { + let nonce = q.pop_front().expect("mock nonce queue exhausted"); + nonces.insert(*pk, nonce + 1); + if missing_q.pop_front().unwrap_or(false) { + missing_metadata.insert(*pk); + } + } + Ok(CommitNonceFetchResult { + nonces, + missing_metadata, + }) } async fn fetch_current_commit_nonces( @@ -1481,6 +1514,11 @@ mod tests { self } + fn missing_metadata(mut self, missing_metadata: Vec) -> Self { + self.inner = self.inner.with_missing_metadata(missing_metadata); + self + } + fn capacity(mut self, n: usize) -> Self { self.capacity = Some(n.try_into().unwrap()); self From 6d7a02f4f91407ec898656b2e4b8adf12b140669 Mon Sep 17 00:00:00 2001 From: Gabriele Picco Date: Sat, 4 Jul 2026 16:33:31 +0800 Subject: [PATCH 09/16] Preserve Token-2022 ATA immutable owner --- .agents/specs/validator-specification.md | 7 ++++--- docs/mimd-rent-pending-ephemeral-ata.md | 8 ++++---- .../process_create_rent_pending_ata.rs | 12 ++++++++++-- 3 files changed, 18 insertions(+), 9 deletions(-) diff --git a/.agents/specs/validator-specification.md b/.agents/specs/validator-specification.md index 404e155c6..f9cca74b1 100644 --- a/.agents/specs/validator-specification.md +++ b/.agents/specs/validator-specification.md @@ -362,9 +362,10 @@ local mutation clears the `delegated` flag and the payer can also be one of the undelegated accounts. Token-2022 rent-pending ATA initialization must mirror the mint's default account state extension; a mint whose `DefaultAccountState` is `Frozen` must create a frozen local rent-pending ATA rather than an initialized -one. Rent-pending eATA fee calculation must use an existing base commit nonce -when delegation metadata already exists, and default the nonce to zero only when -that metadata is actually missing. +one. Token-2022 rent-pending ATAs must include `ImmutableOwner` in addition to +any mint-required account extensions. Rent-pending eATA fee calculation must use +an existing base commit nonce when delegation metadata already exists, and +default the nonce to zero only when that metadata is actually missing. ## RPC and router specification diff --git a/docs/mimd-rent-pending-ephemeral-ata.md b/docs/mimd-rent-pending-ephemeral-ata.md index 11edf21ed..4fdbef2c7 100644 --- a/docs/mimd-rent-pending-ephemeral-ata.md +++ b/docs/mimd-rent-pending-ephemeral-ata.md @@ -182,10 +182,10 @@ The processor must: - `confined = false`; - `undelegating = false`; - `remote_slot = 0`. -6. For Token-2022, compute the required token-account length from the mint - extensions and reject required account extensions that cannot be initialized - correctly in the ER. The implementation must not pack a Token-2022 account - into the legacy SPL Token layout. +6. For Token-2022, compute the token-account length from the mint-required + account extensions plus `ImmutableOwner`, and reject required account + extensions that cannot be initialized correctly in the ER. The implementation + must not pack a Token-2022 account into the legacy SPL Token layout. The instruction must be idempotent: diff --git a/programs/magicblock/src/ephemeral_accounts/process_create_rent_pending_ata.rs b/programs/magicblock/src/ephemeral_accounts/process_create_rent_pending_ata.rs index 5008571fa..5ebb0880c 100644 --- a/programs/magicblock/src/ephemeral_accounts/process_create_rent_pending_ata.rs +++ b/programs/magicblock/src/ephemeral_accounts/process_create_rent_pending_ata.rs @@ -189,10 +189,13 @@ fn token_account_shape( } else { Token2022AccountState::Initialized }; - let required_extensions = + let mut required_extensions = ExtensionType::get_required_init_account_extensions( &mint_extensions, ); + if !required_extensions.contains(&ExtensionType::ImmutableOwner) { + required_extensions.push(ExtensionType::ImmutableOwner); + } let len = ExtensionType::try_calculate_account_len::( &required_extensions, ) @@ -462,7 +465,11 @@ mod tests { let ata_after = &accounts[1]; assert_eq!(ata_after.owner(), &TOKEN_2022_PROGRAM_ID); - assert_eq!(ata_after.data().len(), Token2022Account::LEN); + let expected_len = ExtensionType::try_calculate_account_len::< + Token2022Account, + >(&[ExtensionType::ImmutableOwner]) + .unwrap(); + assert_eq!(ata_after.data().len(), expected_len); let token_account = StateWithExtensions::::unpack(ata_after.data()) .unwrap(); @@ -473,6 +480,7 @@ mod tests { token_account.base.close_authority, COption::Some(RENT_PENDING_ATA_CLOSE_AUTHORITY) ); + assert!(token_account.get_extension::().is_ok()); let info = try_get_rent_pending_ata_info(&ata, ata_after).unwrap(); assert_eq!(info.token_program, TOKEN_2022_PROGRAM_ID); From 41fb87586f47f82657d3e802a38833da9b8c9a9f Mon Sep 17 00:00:00 2001 From: Gabriele Picco Date: Sat, 4 Jul 2026 16:38:51 +0800 Subject: [PATCH 10/16] Remove redundant nonce fallback --- .../src/schedule_transactions/mod.rs | 30 ++----------------- 1 file changed, 2 insertions(+), 28 deletions(-) diff --git a/programs/magicblock/src/schedule_transactions/mod.rs b/programs/magicblock/src/schedule_transactions/mod.rs index 943a22713..13f0b2f23 100644 --- a/programs/magicblock/src/schedule_transactions/mod.rs +++ b/programs/magicblock/src/schedule_transactions/mod.rs @@ -162,10 +162,7 @@ pub(crate) fn fetch_commit_nonces_with_missing_as_zero( missing_as_zero: &HashSet, ) -> Result, InstructionError> { match fetch_current_commit_nonces(commits) { - Ok(mut nonces) => { - fill_missing_commit_nonces(&mut nonces, commits, missing_as_zero)?; - Ok(nonces) - } + Ok(nonces) => Ok(nonces), Err(InstructionError::Custom(MISSING_COMMIT_NONCE_ERR)) => { fetch_commit_nonces_individually(commits, missing_as_zero) } @@ -180,12 +177,7 @@ fn fetch_commit_nonces_individually( let mut nonces = HashMap::with_capacity(commits.len()); for account in commits { match fetch_current_commit_nonces(std::slice::from_ref(account)) { - Ok(mut fetched) => { - fill_missing_commit_nonces( - &mut fetched, - std::slice::from_ref(account), - missing_as_zero, - )?; + Ok(fetched) => { nonces.extend(fetched); } Err(InstructionError::Custom(MISSING_COMMIT_NONCE_ERR)) @@ -199,24 +191,6 @@ fn fetch_commit_nonces_individually( Ok(nonces) } -fn fill_missing_commit_nonces( - nonces: &mut HashMap, - commits: &[CommittedAccount], - missing_as_zero: &HashSet, -) -> Result<(), InstructionError> { - for account in commits { - if nonces.contains_key(&account.pubkey) { - continue; - } - if missing_as_zero.contains(&account.pubkey) { - nonces.insert(account.pubkey, 0); - } else { - return Err(InstructionError::Custom(MISSING_COMMIT_NONCE_ERR)); - } - } - Ok(()) -} - pub(crate) fn magic_fee_vault_pubkey() -> Pubkey { let validator_authority = crate::validator::validator_authority_id(); Pubkey::find_program_address( From 2a80d96fa2dfc0628b82fdf3b596df75045ab3f3 Mon Sep 17 00:00:00 2001 From: Gabriele Picco Date: Sat, 4 Jul 2026 16:58:13 +0800 Subject: [PATCH 11/16] Use existing rent-payer metadata --- .../crates/magicblock-committor-service.md | 3 + .agents/specs/validator-specification.md | 3 + .../src/tasks/task_builder.rs | 62 ++++++++++++------- 3 files changed, 46 insertions(+), 22 deletions(-) diff --git a/.agents/context/crates/magicblock-committor-service.md b/.agents/context/crates/magicblock-committor-service.md index cc8b650a5..c267543a2 100644 --- a/.agents/context/crates/magicblock-committor-service.md +++ b/.agents/context/crates/magicblock-committor-service.md @@ -213,6 +213,9 @@ metadata to current nonce `0` only for rent-pending eATAs, otherwise it uses the fetched base nonce. If an eATA already exists and is delegated to another validator, the e-token delegate task is the expected validator-mismatch failure gate before any DLP commit/undelegation task is allowed to succeed. +Commit-and-undelegate reimbursement also fetches existing base DLP metadata +first; it defaults rent reimbursement to the validator only when rent-pending +eATA metadata is actually missing. ### Delivery preparation and cleanup flow diff --git a/.agents/specs/validator-specification.md b/.agents/specs/validator-specification.md index f9cca74b1..f775f54c4 100644 --- a/.agents/specs/validator-specification.md +++ b/.agents/specs/validator-specification.md @@ -366,6 +366,9 @@ one. Token-2022 rent-pending ATAs must include `ImmutableOwner` in addition to any mint-required account extensions. Rent-pending eATA fee calculation must use an existing base commit nonce when delegation metadata already exists, and default the nonce to zero only when that metadata is actually missing. +Commit-and-undelegate rent reimbursement must use an existing base metadata rent +payer when present, defaulting to the validator only when rent-pending eATA +metadata is actually missing. ## RPC and router specification diff --git a/magicblock-committor-service/src/tasks/task_builder.rs b/magicblock-committor-service/src/tasks/task_builder.rs index 1a7fb62a6..6f23cadd3 100644 --- a/magicblock-committor-service/src/tasks/task_builder.rs +++ b/magicblock-committor-service/src/tasks/task_builder.rs @@ -466,7 +466,6 @@ impl TasksBuilder for TaskBuilderImpl { async fn create_undelegate_tasks( commit_and_undelegate: &CommitAndUndelegate, info_fetcher: &Arc, - rent_pending_reimbursements: &HashMap, missing_metadata_reimbursements: &HashMap, ) -> TaskBuilderResult> { // Get rent reimbursments for undelegated accounts @@ -474,11 +473,10 @@ impl TasksBuilder for TaskBuilderImpl { let mut min_context_slot = 0; let pubkeys = accounts .iter() - .filter_map(|account| { + .map(|account| { min_context_slot = std::cmp::max(min_context_slot, account.remote_slot); - (!rent_pending_reimbursements.contains_key(&account.pubkey)) - .then_some(account.pubkey) + account.pubkey }) .collect::>(); let rent_reimbursements = info_fetcher @@ -493,10 +491,6 @@ impl TasksBuilder for TaskBuilderImpl { let mut tasks = Vec::with_capacity(accounts.len()); for account in accounts { let rent_reimbursement = if let Some(rent_reimbursement) = - rent_pending_reimbursements.get(&account.pubkey) - { - *rent_reimbursement - } else if let Some(rent_reimbursement) = rent_reimbursements.get(&account.pubkey) { *rent_reimbursement @@ -525,7 +519,7 @@ impl TasksBuilder for TaskBuilderImpl { &all_committed_accounts, validator, ); - let rent_pending_reimbursements = intent_bundle + let mut missing_metadata_reimbursements = intent_bundle .intent_bundle .rent_pending_ata_materializations .iter() @@ -533,15 +527,11 @@ impl TasksBuilder for TaskBuilderImpl { (materialization.eata_pubkey, materialization.validator) }) .collect::>(); - let missing_metadata_reimbursements = recovered_materializations - .into_iter() - .filter(|(pubkey, _)| { - !rent_pending_reimbursements.contains_key(pubkey) - }) - .map(|(pubkey, materialization)| { - (pubkey, materialization.validator) - }) - .collect::>(); + missing_metadata_reimbursements.extend( + recovered_materializations.into_iter().map( + |(pubkey, materialization)| (pubkey, materialization.validator), + ), + ); let mut futures = Vec::with_capacity(2); if let Some(ref value) = intent_bundle.intent_bundle.commit { @@ -555,7 +545,6 @@ impl TasksBuilder for TaskBuilderImpl { futures.push(create_undelegate_tasks( value, info_fetcher, - &rent_pending_reimbursements, &missing_metadata_reimbursements, )); } @@ -566,7 +555,6 @@ impl TasksBuilder for TaskBuilderImpl { futures.push(create_undelegate_tasks( value, info_fetcher, - &rent_pending_reimbursements, &missing_metadata_reimbursements, )); } @@ -825,6 +813,10 @@ mod tests { next_nonce: u64, } + fn existing_metadata_rent_reimbursement() -> Pubkey { + Pubkey::new_from_array([7; 32]) + } + #[async_trait] impl TaskInfoFetcher for ExistingMetadataFetcher { async fn fetch_next_commit_nonces( @@ -872,8 +864,10 @@ mod tests { pubkeys: &[Pubkey], _min_context_slot: u64, ) -> TaskInfoFetcherResult> { - assert!(pubkeys.is_empty()); - Ok(Vec::new()) + Ok(pubkeys + .iter() + .map(|_| existing_metadata_rent_reimbursement()) + .collect()) } async fn get_base_accounts( @@ -1110,6 +1104,30 @@ mod tests { assert_eq!(undelegate.rent_reimbursement, validator); } + #[tokio::test] + async fn rent_pending_commit_and_undelegate_uses_existing_reimbursement() { + let fetcher = Arc::new(ExistingMetadataFetcher { next_nonce: 2 }); + let (intent, validator) = rent_pending_intent(true); + + let tasks = + TaskBuilderImpl::finalize_tasks(&fetcher, &intent, &validator) + .await + .unwrap(); + + assert!(matches!( + tasks.as_slice(), + [BaseTaskImpl::Finalize(_), BaseTaskImpl::Undelegate(_)] + )); + let BaseTaskImpl::Undelegate(undelegate) = &tasks[1] else { + panic!("expected undelegate task"); + }; + assert_ne!(undelegate.rent_reimbursement, validator); + assert_eq!( + undelegate.rent_reimbursement, + existing_metadata_rent_reimbursement() + ); + } + #[tokio::test] async fn recovered_rent_pending_undelegation_defaults_reimbursement() { let fetcher = Arc::new(EmptyFetcher); From 9ab55195d717d970b3e342871849bda20bf31c45 Mon Sep 17 00:00:00 2001 From: Gabriele Picco Date: Sat, 4 Jul 2026 17:21:14 +0800 Subject: [PATCH 12/16] Require explicit rent-pending metadata --- .../crates/magicblock-committor-service.md | 3 + .agents/specs/validator-specification.md | 3 + .../src/tasks/task_builder.rs | 188 +++++------------- 3 files changed, 57 insertions(+), 137 deletions(-) diff --git a/.agents/context/crates/magicblock-committor-service.md b/.agents/context/crates/magicblock-committor-service.md index c267543a2..2bf42e9bd 100644 --- a/.agents/context/crates/magicblock-committor-service.md +++ b/.agents/context/crates/magicblock-committor-service.md @@ -213,6 +213,9 @@ metadata to current nonce `0` only for rent-pending eATAs, otherwise it uses the fetched base nonce. If an eATA already exists and is delegated to another validator, the e-token delegate task is the expected validator-mismatch failure gate before any DLP commit/undelegation task is allowed to succeed. +Only explicit scheduled materialization metadata authorizes this path; eATA +shape alone is treated as a normal commit input and missing base metadata +remains an error. Commit-and-undelegate reimbursement also fetches existing base DLP metadata first; it defaults rent reimbursement to the validator only when rent-pending eATA metadata is actually missing. diff --git a/.agents/specs/validator-specification.md b/.agents/specs/validator-specification.md index f775f54c4..e3e22f199 100644 --- a/.agents/specs/validator-specification.md +++ b/.agents/specs/validator-specification.md @@ -369,6 +369,9 @@ default the nonce to zero only when that metadata is actually missing. Commit-and-undelegate rent reimbursement must use an existing base metadata rent payer when present, defaulting to the validator only when rent-pending eATA metadata is actually missing. +The committor must not infer rent-pending materialization from eATA account +shape alone; without explicit scheduled materialization metadata, missing base +metadata remains an error. ## RPC and router specification diff --git a/magicblock-committor-service/src/tasks/task_builder.rs b/magicblock-committor-service/src/tasks/task_builder.rs index 6f23cadd3..7d43cb9e2 100644 --- a/magicblock-committor-service/src/tasks/task_builder.rs +++ b/magicblock-committor-service/src/tasks/task_builder.rs @@ -6,11 +6,7 @@ use std::{ use async_trait::async_trait; use futures_util::future::try_join_all; use magicblock_core::{ - intent::CommittedAccount, - token_programs::{ - try_derive_eata_address_and_bump, EphemeralAta, - RentPendingAtaMaterialization, EATA_PROGRAM_ID, - }, + intent::CommittedAccount, token_programs::RentPendingAtaMaterialization, }; use magicblock_program::magic_scheduled_base_intent::{ BaseAction, CommitAndUndelegate, CommitType, ScheduledIntentBundle, @@ -156,56 +152,6 @@ impl TaskBuilderImpl { .await } - fn derive_recovered_rent_pending_materialization( - account: &CommittedAccount, - validator: &Pubkey, - ) -> Option { - if account.account.owner != EATA_PROGRAM_ID { - return None; - } - - let eata = EphemeralAta::try_from_account_data(&account.account.data)?; - if eata.owner == Pubkey::default() || eata.mint == Pubkey::default() { - return None; - } - let (expected_eata, expected_bump) = - try_derive_eata_address_and_bump(&eata.owner, &eata.mint)?; - if expected_eata != account.pubkey || expected_bump != eata.bump { - return None; - } - - // Recovered rows only retain eATA state; the committor eATA tasks - // consume eATA, wallet owner, mint, and validator. - Some(RentPendingAtaMaterialization { - ata_pubkey: Pubkey::default(), - eata_pubkey: account.pubkey, - token_program: Pubkey::default(), - wallet_owner: eata.owner, - mint: eata.mint, - token_account_data_len: 0, - validator: *validator, - delegated_payer: Pubkey::default(), - delegated_vault: Pubkey::default(), - }) - } - - fn derive_recovered_rent_pending_materializations( - accounts: &[CommittedAccount], - validator: &Pubkey, - ) -> HashMap { - accounts - .iter() - .filter_map(|account| { - Self::derive_recovered_rent_pending_materialization( - account, validator, - ) - .map(|materialization| { - (materialization.eata_pubkey, materialization) - }) - }) - .collect() - } - fn rent_pending_materialization_tasks( materializations: impl IntoIterator, ) -> Vec { @@ -226,30 +172,15 @@ impl TaskBuilderImpl { async fn fetch_commit_stage_info( intent_bundle: &ScheduledIntentBundle, task_info_fetcher: &Arc, - validator: &Pubkey, persister: &Option

, ) -> TaskBuilderResult { let all_committed_accounts = intent_bundle.get_all_committed_accounts(); - let explicit_materializations = intent_bundle + let rent_pending_pubkeys = intent_bundle .intent_bundle .rent_pending_ata_materializations .iter() - .map(|materialization| { - (materialization.eata_pubkey, materialization.clone()) - }) - .collect::>(); - let recovered_materializations = - Self::derive_recovered_rent_pending_materializations( - &all_committed_accounts, - validator, - ); - let mut rent_pending_pubkeys = explicit_materializations - .keys() - .copied() + .map(|materialization| materialization.eata_pubkey) .collect::>(); - rent_pending_pubkeys.extend(recovered_materializations.keys().copied()); - rent_pending_pubkeys.sort_unstable(); - rent_pending_pubkeys.dedup(); // Get commit nonces and base accounts let min_context_slot = all_committed_accounts @@ -272,21 +203,10 @@ impl TaskBuilderImpl { ); let commit_nonce_result = commit_ids.map_err(TaskBuilderError::CommitTasksBuildError)?; - let mut rent_pending_ata_materializations = intent_bundle + let rent_pending_ata_materializations = intent_bundle .intent_bundle .rent_pending_ata_materializations .clone(); - rent_pending_ata_materializations.extend( - commit_nonce_result - .missing_metadata - .iter() - .filter(|pubkey| { - !explicit_materializations.contains_key(pubkey) - }) - .filter_map(|pubkey| { - recovered_materializations.get(pubkey).cloned() - }), - ); let commit_nonces = commit_nonce_result.nonces; let base_accounts = base_accounts.unwrap_or_else(|err| { tracing::warn!(intent_id = intent_bundle.id, error = ?err, "Failed to fetch base accounts, falling back to CommitState"); @@ -343,7 +263,7 @@ impl TasksBuilder for TaskBuilderImpl { async fn commit_tasks( task_info_fetcher: &Arc, intent_bundle: &ScheduledIntentBundle, - validator: &Pubkey, + _validator: &Pubkey, persister: &Option

, ) -> TaskBuilderResult> { let mut tasks = Vec::new(); @@ -360,7 +280,6 @@ impl TasksBuilder for TaskBuilderImpl { } = Self::fetch_commit_stage_info( intent_bundle, task_info_fetcher, - validator, persister, ) .await?; @@ -418,7 +337,7 @@ impl TasksBuilder for TaskBuilderImpl { async fn finalize_tasks( info_fetcher: &Arc, intent_bundle: &ScheduledIntentBundle, - validator: &Pubkey, + _validator: &Pubkey, ) -> TaskBuilderResult> { // Helper to create a finalize task fn finalize_task(account: &CommittedAccount) -> BaseTaskImpl { @@ -513,13 +432,7 @@ impl TasksBuilder for TaskBuilderImpl { } let mut tasks = Vec::new(); - let all_committed_accounts = intent_bundle.get_all_committed_accounts(); - let recovered_materializations = - TaskBuilderImpl::derive_recovered_rent_pending_materializations( - &all_committed_accounts, - validator, - ); - let mut missing_metadata_reimbursements = intent_bundle + let missing_metadata_reimbursements = intent_bundle .intent_bundle .rent_pending_ata_materializations .iter() @@ -527,11 +440,6 @@ impl TasksBuilder for TaskBuilderImpl { (materialization.eata_pubkey, materialization.validator) }) .collect::>(); - missing_metadata_reimbursements.extend( - recovered_materializations.into_iter().map( - |(pubkey, materialization)| (pubkey, materialization.validator), - ), - ); let mut futures = Vec::with_capacity(2); if let Some(ref value) = intent_bundle.intent_bundle.commit { @@ -718,8 +626,8 @@ pub type TaskBuilderResult = Result; #[cfg(test)] mod tests { use magicblock_core::token_programs::{ - EphemeralAta, RentPendingAtaMaterialization, EATA_PROGRAM_ID, - TOKEN_PROGRAM_ID, + try_derive_eata_address_and_bump, EphemeralAta, + RentPendingAtaMaterialization, EATA_PROGRAM_ID, TOKEN_PROGRAM_ID, }; use magicblock_program::magic_scheduled_base_intent::{ MagicIntentBundle, ScheduledIntentBundle, @@ -754,10 +662,24 @@ mod tests { _min_context_slot: u64, missing_metadata_as_zero: &[Pubkey], ) -> TaskInfoFetcherResult { - assert_eq!(pubkeys, missing_metadata_as_zero); + let allowed = missing_metadata_as_zero + .iter() + .copied() + .collect::>(); + let mut nonces = HashMap::new(); + let mut missing_metadata = HashSet::new(); + for pubkey in pubkeys { + if !allowed.contains(pubkey) { + return Err(TaskInfoFetcherError::AccountNotFoundError( + *pubkey, + )); + } + nonces.insert(*pubkey, 1); + missing_metadata.insert(*pubkey); + } Ok(CommitNonceFetchResult { - nonces: pubkeys.iter().map(|pubkey| (*pubkey, 1)).collect(), - missing_metadata: pubkeys.iter().copied().collect(), + nonces, + missing_metadata, }) } @@ -837,9 +759,8 @@ mod tests { &self, pubkeys: &[Pubkey], _min_context_slot: u64, - missing_metadata_as_zero: &[Pubkey], + _missing_metadata_as_zero: &[Pubkey], ) -> TaskInfoFetcherResult { - assert_eq!(pubkeys, missing_metadata_as_zero); Ok(CommitNonceFetchResult::from_nonces( pubkeys .iter() @@ -941,7 +862,7 @@ mod tests { ) } - fn recovered_rent_pending_intent( + fn eata_intent_without_materialization_metadata( commit_and_undelegate: bool, ) -> (ScheduledIntentBundle, Pubkey) { let wallet_owner = Pubkey::new_unique(); @@ -1024,35 +945,27 @@ mod tests { } #[tokio::test] - async fn recovered_rent_pending_commit_derives_eata_materialization() { + async fn eata_commit_without_materialization_metadata_requires_base_metadata( + ) { let fetcher = Arc::new(EmptyFetcher); - let (intent, validator) = recovered_rent_pending_intent(false); + let (intent, validator) = + eata_intent_without_materialization_metadata(false); - let tasks = TaskBuilderImpl::commit_tasks( + let err = TaskBuilderImpl::commit_tasks( &fetcher, &intent, &validator, &None::, ) .await - .unwrap(); + .unwrap_err(); assert!(matches!( - tasks.as_slice(), - [ - BaseTaskImpl::InitializeRentPendingAta(_), - BaseTaskImpl::DelegateRentPendingAta(_), - BaseTaskImpl::Commit(_) - ] + err, + TaskBuilderError::CommitTasksBuildError( + TaskInfoFetcherError::AccountNotFoundError(_) + ) )); - let delegate_ix = tasks[1].instruction(&validator); - assert_eq!(delegate_ix.program_id, EATA_PROGRAM_ID); - assert_eq!(delegate_ix.data[0], 4); - assert_eq!(&delegate_ix.data[1..], validator.as_ref()); - let BaseTaskImpl::Commit(commit) = &tasks[2] else { - panic!("expected commit task"); - }; - assert_eq!(commit.commit_id, 1); } #[tokio::test] @@ -1129,23 +1042,23 @@ mod tests { } #[tokio::test] - async fn recovered_rent_pending_undelegation_defaults_reimbursement() { + async fn eata_undelegation_without_materialization_metadata_requires_base_reimbursement( + ) { let fetcher = Arc::new(EmptyFetcher); - let (intent, validator) = recovered_rent_pending_intent(true); + let (intent, validator) = + eata_intent_without_materialization_metadata(true); - let tasks = + let err = TaskBuilderImpl::finalize_tasks(&fetcher, &intent, &validator) .await - .unwrap(); + .unwrap_err(); assert!(matches!( - tasks.as_slice(), - [BaseTaskImpl::Finalize(_), BaseTaskImpl::Undelegate(_)] + err, + TaskBuilderError::FinalizedTasksBuildError( + TaskInfoFetcherError::AccountNotFoundError(_) + ) )); - let BaseTaskImpl::Undelegate(undelegate) = &tasks[1] else { - panic!("expected undelegate task"); - }; - assert_eq!(undelegate.rent_reimbursement, validator); } #[tokio::test] @@ -1169,9 +1082,10 @@ mod tests { } #[tokio::test] - async fn recovered_rent_pending_existing_metadata_uses_normal_commit() { + async fn eata_commit_existing_metadata_uses_normal_commit() { let fetcher = Arc::new(ExistingMetadataFetcher { next_nonce: 2 }); - let (intent, validator) = recovered_rent_pending_intent(false); + let (intent, validator) = + eata_intent_without_materialization_metadata(false); let tasks = TaskBuilderImpl::commit_tasks( &fetcher, From 813ebc407421d423a6692facfdbef87f403ca41b Mon Sep 17 00:00:00 2001 From: Gabriele Picco Date: Sat, 4 Jul 2026 17:54:12 +0800 Subject: [PATCH 13/16] Reject stateful rent-pending extensions --- .../crates/magicblock-magic-program-api.md | 5 ++ .agents/specs/validator-specification.md | 8 ++- .../process_create_rent_pending_ata.rs | 71 +++++++++++++++++++ 3 files changed, 81 insertions(+), 3 deletions(-) diff --git a/.agents/context/crates/magicblock-magic-program-api.md b/.agents/context/crates/magicblock-magic-program-api.md index b017dc5d7..420a54aa5 100644 --- a/.agents/context/crates/magicblock-magic-program-api.md +++ b/.agents/context/crates/magicblock-magic-program-api.md @@ -189,6 +189,11 @@ generic ephemeral account. Its account metas are payer signer, writable ATA, mint, and token program. It reuses the former `Unused` bincode slot, so do not insert another variant in that position or assume an unused discriminant remains there. +For Token-2022, rent-pending creation supports default account state plus +marker-only account extensions such as `ImmutableOwner`, +`NonTransferableAccount`, and `PausableAccount`; reject required stateful account +extensions such as `TransferFeeAmount` or `TransferHookAccount` until settlement +preserves extension state. Changing the rent constant, vault pubkey, rent-pending ATA instruction shape, or account-meta shape affects user-visible balance semantics and tests. diff --git a/.agents/specs/validator-specification.md b/.agents/specs/validator-specification.md index e3e22f199..fa1748f59 100644 --- a/.agents/specs/validator-specification.md +++ b/.agents/specs/validator-specification.md @@ -363,9 +363,11 @@ undelegated accounts. Token-2022 rent-pending ATA initialization must mirror the mint's default account state extension; a mint whose `DefaultAccountState` is `Frozen` must create a frozen local rent-pending ATA rather than an initialized one. Token-2022 rent-pending ATAs must include `ImmutableOwner` in addition to -any mint-required account extensions. Rent-pending eATA fee calculation must use -an existing base commit nonce when delegation metadata already exists, and -default the nonce to zero only when that metadata is actually missing. +supported marker-only account extensions. Required stateful account extensions +such as `TransferFeeAmount` or `TransferHookAccount` are rejected until +settlement preserves their state. Rent-pending eATA fee calculation must use an +existing base commit nonce when delegation metadata already exists, and default +the nonce to zero only when that metadata is actually missing. Commit-and-undelegate rent reimbursement must use an existing base metadata rent payer when present, defaulting to the validator only when rent-pending eATA metadata is actually missing. diff --git a/programs/magicblock/src/ephemeral_accounts/process_create_rent_pending_ata.rs b/programs/magicblock/src/ephemeral_accounts/process_create_rent_pending_ata.rs index 5ebb0880c..11cda2cc4 100644 --- a/programs/magicblock/src/ephemeral_accounts/process_create_rent_pending_ata.rs +++ b/programs/magicblock/src/ephemeral_accounts/process_create_rent_pending_ata.rs @@ -193,6 +193,11 @@ fn token_account_shape( ExtensionType::get_required_init_account_extensions( &mint_extensions, ); + if required_extensions.iter().any(|extension| { + !is_supported_rent_pending_account_extension(*extension) + }) { + return Err(InstructionError::InvalidAccountData); + } if !required_extensions.contains(&ExtensionType::ImmutableOwner) { required_extensions.push(ExtensionType::ImmutableOwner); } @@ -208,6 +213,17 @@ fn token_account_shape( } } +fn is_supported_rent_pending_account_extension( + extension: ExtensionType, +) -> bool { + matches!( + extension, + ExtensionType::ImmutableOwner + | ExtensionType::NonTransferableAccount + | ExtensionType::PausableAccount + ) +} + fn initialize_token_data( data: &mut [u8], token_program: &Pubkey, @@ -295,6 +311,7 @@ mod tests { use spl_token_2022::extension::{ immutable_owner::ImmutableOwner, non_transferable::{NonTransferable, NonTransferableAccount}, + transfer_fee::TransferFeeConfig, }; use super::*; @@ -339,6 +356,9 @@ mod tests { ExtensionType::NonTransferable => { state.init_extension::(false).unwrap(); } + ExtensionType::TransferFeeConfig => { + state.init_extension::(false).unwrap(); + } _ => panic!("unsupported test extension"), } } @@ -558,6 +578,57 @@ mod tests { assert!(try_get_rent_pending_ata_info(&ata, ata_after).is_some()); } + #[test] + fn create_rent_pending_token_2022_ata_rejects_stateful_account_extensions() + { + let payer = Pubkey::new_unique(); + let wallet_owner = Pubkey::new_unique(); + let mint = Pubkey::new_unique(); + let ata = derive_ata_with_token_program( + &wallet_owner, + &mint, + &TOKEN_2022_PROGRAM_ID, + ); + + let ix = Instruction::new_with_bincode( + crate::id(), + &MagicBlockInstruction::CreateRentPendingAta { + wallet_owner, + mint, + token_program: TOKEN_2022_PROGRAM_ID, + }, + vec![ + AccountMeta::new(payer, true), + AccountMeta::new(ata, false), + AccountMeta::new_readonly(mint, false), + AccountMeta::new_readonly(TOKEN_2022_PROGRAM_ID, false), + ], + ); + + process_instruction( + &ix.data, + vec![ + ( + payer, + AccountSharedData::new(1_000_000, 0, &system_program::id()), + ), + (ata, AccountSharedData::new(0, 0, &system_program::id())), + ( + mint, + token_2022_mint_account(&[ + ExtensionType::TransferFeeConfig, + ]), + ), + ( + TOKEN_2022_PROGRAM_ID, + AccountSharedData::new(1, 0, &native_loader::id()), + ), + ], + ix.accounts, + Err(InstructionError::InvalidAccountData), + ); + } + #[test] fn create_rent_pending_token_2022_ata_honors_default_account_state() { let payer = Pubkey::new_unique(); From c217614dedbb7055d11aa04e7df2b281c04561d2 Mon Sep 17 00:00:00 2001 From: Gabriele Picco Date: Sat, 4 Jul 2026 18:21:04 +0800 Subject: [PATCH 14/16] Allow rent-pending ATA reprojection --- .../context/crates/magicblock-chainlink.md | 1 + .../chainlink/fetch_cloner/ata_projection.rs | 8 +- .../src/chainlink/fetch_cloner/tests.rs | 99 +++++++++++++++++++ 3 files changed, 106 insertions(+), 2 deletions(-) diff --git a/.agents/context/crates/magicblock-chainlink.md b/.agents/context/crates/magicblock-chainlink.md index 6fa6e0918..c6e54ac06 100644 --- a/.agents/context/crates/magicblock-chainlink.md +++ b/.agents/context/crates/magicblock-chainlink.md @@ -239,6 +239,7 @@ Chainlink has special handling for associated token accounts and ephemeral ATAs: - It subscribes to both ATA and eATA using `SubscriptionReason::AtaProjection`. - If the eATA exists, has a delegation record for this validator, and can be projected, Chainlink clones a projected delegated ATA into the local bank. - Projection preserves the base ATA's owner and data length, which is important for Token-2022 extensions. +- eATA subscription updates do not overwrite an already-delegated base ATA, except for delegated rent-pending ATA markers detected by `try_get_rent_pending_ata_info`; projecting those markers clears the rent-pending close-authority sentinel while preserving delegated local execution state. - Missing eATAs can be remembered in `known_empty_eatas`, but only after confirmed `NotFound` while an eATA subscription is live. - Raw eATA PDAs are not marked delegated directly; their state is projected into the corresponding base ATA. diff --git a/magicblock-chainlink/src/chainlink/fetch_cloner/ata_projection.rs b/magicblock-chainlink/src/chainlink/fetch_cloner/ata_projection.rs index 7248f0a17..406cb2c5d 100644 --- a/magicblock-chainlink/src/chainlink/fetch_cloner/ata_projection.rs +++ b/magicblock-chainlink/src/chainlink/fetch_cloner/ata_projection.rs @@ -5,7 +5,7 @@ use futures_util::future::join_all; use magicblock_accounts_db::traits::AccountsBank; use magicblock_core::token_programs::{ is_ata, try_derive_eata_address_and_bump, try_derive_supported_ata_pubkeys, - AtaInfo, EphemeralAta, EATA_PROGRAM_ID, + try_get_rent_pending_ata_info, AtaInfo, EphemeralAta, EATA_PROGRAM_ID, }; use magicblock_metrics::metrics; use solana_account::{AccountSharedData, ReadableAccount}; @@ -203,7 +203,11 @@ where } }; - if base_ata.delegated() || base_ata.undelegating() { + let is_rent_pending_base_ata = + try_get_rent_pending_ata_info(&ata_pubkey, &base_ata).is_some(); + if base_ata.undelegating() + || (base_ata.delegated() && !is_rent_pending_base_ata) + { return None; } let projected_ata = maybe_project_delegated_ata_from_eata( diff --git a/magicblock-chainlink/src/chainlink/fetch_cloner/tests.rs b/magicblock-chainlink/src/chainlink/fetch_cloner/tests.rs index 0335f687e..2d1a899c4 100644 --- a/magicblock-chainlink/src/chainlink/fetch_cloner/tests.rs +++ b/magicblock-chainlink/src/chainlink/fetch_cloner/tests.rs @@ -1,6 +1,9 @@ use std::{collections::HashMap, sync::Arc, time::Duration}; use dlp_api::state::DelegationRecord; +use magicblock_core::token_programs::{ + try_get_rent_pending_ata_info, RENT_PENDING_ATA_CLOSE_AUTHORITY, +}; use magicblock_metrics::metrics::{ chainlink_pending_fetch_accounts_value, chainlink_pending_fetch_waiters_gauge_value, @@ -6585,6 +6588,102 @@ async fn test_projected_ata_clone_request_from_eata_update_requires_ata_in_bank( ); } +#[tokio::test] +async fn test_projected_ata_clone_request_from_eata_update_clears_rent_pending_marker( +) { + init_logger(); + let validator_keypair = Keypair::new(); + let validator_pubkey = validator_keypair.pubkey(); + let wallet_owner = random_pubkey(); + let mint = random_pubkey(); + const CURRENT_SLOT: u64 = 100; + const CHAIN_EATA_AMOUNT: u64 = 777; + const RENT_PENDING_ATA_AMOUNT: u64 = 999; + + let eata_pubkey = derive_eata(&wallet_owner, &mint); + let ata_pubkey = derive_ata(&wallet_owner, &mint); + let eata_account = + create_eata_account(&wallet_owner, &mint, CHAIN_EATA_AMOUNT, true); + + let FetcherTestCtx { + accounts_bank, + fetch_cloner, + rpc_client, + .. + } = setup( + [(eata_pubkey, eata_account.clone())], + CURRENT_SLOT, + validator_keypair.insecure_clone(), + ) + .await; + + add_delegation_record_for( + &rpc_client, + eata_pubkey, + validator_pubkey, + EATA_PROGRAM_ID, + ); + + let mut rent_pending_ata = create_ata_account(&wallet_owner, &mint); + let mut rent_pending_token = + SplAccount::unpack(&rent_pending_ata.data).unwrap(); + rent_pending_token.amount = RENT_PENDING_ATA_AMOUNT; + rent_pending_token.close_authority = + COption::Some(RENT_PENDING_ATA_CLOSE_AUTHORITY); + SplAccount::pack(rent_pending_token, &mut rent_pending_ata.data).unwrap(); + let mut rent_pending_ata_shared = AccountSharedData::from(rent_pending_ata); + rent_pending_ata_shared.set_remote_slot(CURRENT_SLOT - 1); + rent_pending_ata_shared.set_delegated(true); + assert!( + try_get_rent_pending_ata_info(&ata_pubkey, &rent_pending_ata_shared) + .is_some(), + "test setup must create a rent-pending ATA marker", + ); + accounts_bank.insert(ata_pubkey, rent_pending_ata_shared); + + let (deleg_record, _) = fetch_cloner + .fetch_and_parse_delegation_record( + eata_pubkey, + CURRENT_SLOT, + AccountFetchOrigin::GetAccount, + ) + .await + .expect("delegation record should resolve"); + + let mut eata_shared = AccountSharedData::from(eata_account); + eata_shared.set_remote_slot(CURRENT_SLOT); + + let projected_ata_request = fetch_cloner + .maybe_build_projected_ata_clone_request_from_subscription_update( + eata_pubkey, + &eata_shared, + Some(&deleg_record), + &DelegationActions::default(), + ) + .await + .expect("rent-pending ATA should be replaced by projected eATA state"); + + assert_eq!(projected_ata_request.pubkey, ata_pubkey); + assert!(projected_ata_request.account.delegated()); + assert_eq!(projected_ata_request.account.remote_slot(), CURRENT_SLOT); + assert!( + try_get_rent_pending_ata_info( + &ata_pubkey, + &projected_ata_request.account, + ) + .is_none(), + "projected ATA must clear the rent-pending marker", + ); + + let projected_token = + SplAccount::unpack(projected_ata_request.account.data()).unwrap(); + assert_eq!(projected_token.amount, CHAIN_EATA_AMOUNT); + assert_eq!( + projected_token.close_authority, + COption::Some(Pubkey::default()) + ); +} + #[tokio::test] async fn test_fetch_and_parse_delegation_record_releases_direct_ref_when_already_watched( ) { From 887ce08aa558ca116b073078f45ad1953efe5393 Mon Sep 17 00:00:00 2001 From: Gabriele Picco Date: Sat, 4 Jul 2026 18:48:35 +0800 Subject: [PATCH 15/16] Allow recorded rent-pending ATA undelegation --- .agents/context/crates/magicblock-core.md | 2 +- .agents/specs/validator-specification.md | 6 +- magicblock-core/src/tls.rs | 20 ++++ magicblock-core/src/token_programs.rs | 72 ++++++++++++-- .../src/executor/processing.rs | 93 ++++++++++++++++++- .../src/magic_scheduled_base_intent.rs | 4 + .../process_schedule_commit.rs | 4 + 7 files changed, 187 insertions(+), 14 deletions(-) diff --git a/.agents/context/crates/magicblock-core.md b/.agents/context/crates/magicblock-core.md index a2c93ce4a..4ba500a01 100644 --- a/.agents/context/crates/magicblock-core.md +++ b/.agents/context/crates/magicblock-core.md @@ -137,7 +137,7 @@ Use `CoordinationMode::current()`, `needs_validator_signer()`, `should_schedule_ ### Execution TLS -`tls::ExecutionTlsStash` is a thread-local queue currently used for `TaskRequest`s emitted by Magic Program task scheduling/cancel instructions and for newly created rent-pending ATA pubkeys that need post-execution validation. The processor clears the stash around execution, drains scheduled tasks after a successful transaction path, and verifies newly created rent-pending ATAs before local state is committed. Do not use it as a cross-thread channel or persistent store. +`tls::ExecutionTlsStash` is a thread-local queue currently used for `TaskRequest`s emitted by Magic Program task scheduling/cancel instructions, newly created rent-pending ATA pubkeys that need post-execution validation, and rent-pending ATA materialization pubkeys recorded during scheduling. The processor clears the stash around execution, drains scheduled tasks after a successful transaction path, and verifies newly created rent-pending ATAs before local state is committed. The materialization marker lets that verifier accept a rent-pending ATA that was created, funded, recorded for materialization, and then marked undelegating in the same transaction. Do not use TLS as a cross-thread channel or persistent store. ### Token/eATA helpers diff --git a/.agents/specs/validator-specification.md b/.agents/specs/validator-specification.md index fa1748f59..ff603de3a 100644 --- a/.agents/specs/validator-specification.md +++ b/.agents/specs/validator-specification.md @@ -344,7 +344,11 @@ that marker is not rent-pending. New local rent-pending ATA creation is free, but the same transaction must leave the newly created account with a positive token amount. The processor rejects and rolls back a creation transaction that leaves a newly created rent-pending -ATA at zero balance. +ATA at zero balance. The same transaction may also schedule commit-and-undelegate +for that newly created ATA after materialization metadata has been recorded; in +that case the post-creation verifier accepts the undelegating token-account shape +only because the execution TLS materialization marker proves scheduling already +captured the rent-pending metadata. When a rent-pending ATA is scheduled for commit or commit-and-undelegate, the Magic Program records explicit materialization metadata, charges the delegated diff --git a/magicblock-core/src/tls.rs b/magicblock-core/src/tls.rs index b42515449..cfe056b58 100644 --- a/magicblock-core/src/tls.rs +++ b/magicblock-core/src/tls.rs @@ -7,6 +7,7 @@ use solana_pubkey::Pubkey; pub struct ExecutionTlsStash { tasks: VecDeque, created_rent_pending_atas: VecDeque, + recorded_rent_pending_ata_materializations: VecDeque, // TODO(bmuddha/taco-paco): intents should go in here intents: VecDeque<()>, } @@ -37,10 +38,29 @@ impl ExecutionTlsStash { }) } + pub fn register_recorded_rent_pending_ata_materialization(pubkey: Pubkey) { + EXECUTION_TLS_STASH.with_borrow_mut(|stash| { + stash + .recorded_rent_pending_ata_materializations + .push_back(pubkey) + }); + } + + pub fn has_recorded_rent_pending_ata_materialization( + pubkey: &Pubkey, + ) -> bool { + EXECUTION_TLS_STASH.with_borrow(|stash| { + stash + .recorded_rent_pending_ata_materializations + .contains(pubkey) + }) + } + pub fn clear() { EXECUTION_TLS_STASH.with_borrow_mut(|stash| { stash.tasks.clear(); stash.created_rent_pending_atas.clear(); + stash.recorded_rent_pending_ata_materializations.clear(); stash.intents.clear(); }) } diff --git a/magicblock-core/src/token_programs.rs b/magicblock-core/src/token_programs.rs index c8d6c2c4c..f36f63aa5 100644 --- a/magicblock-core/src/token_programs.rs +++ b/magicblock-core/src/token_programs.rs @@ -437,17 +437,55 @@ pub fn try_get_rent_pending_ata_info( return None; } - let token_program_owner = account.owner(); - let is_spl_token = *token_program_owner == TOKEN_PROGRAM_ID; - let is_token_2022 = *token_program_owner == TOKEN_2022_PROGRAM_ID; - if !(is_spl_token || is_token_2022) { + let token_program = account.owner(); + if !is_supported_token_program(token_program) { return None; } + try_get_rent_pending_ata_info_for_token_program( + pubkey, + account, + token_program, + ) +} - let token_account = parse_token_account_for_rent_pending( - token_program_owner, - account.data(), - )?; +/// Parses a rent-pending ATA after scheduling has marked it undelegating. +/// Callers must separately prove rent-pending materialization metadata was +/// recorded before trusting this shape. +pub fn try_get_undelegating_rent_pending_ata_info( + pubkey: &Pubkey, + account: &AccountSharedData, +) -> Option { + if account.delegated() + || account.ephemeral() + || account.confined() + || !account.undelegating() + { + return None; + } + + [TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID] + .into_iter() + .find_map(|token_program| { + try_get_rent_pending_ata_info_for_token_program( + pubkey, + account, + &token_program, + ) + }) +} + +fn is_supported_token_program(token_program: &Pubkey) -> bool { + *token_program == TOKEN_PROGRAM_ID + || *token_program == TOKEN_2022_PROGRAM_ID +} + +fn try_get_rent_pending_ata_info_for_token_program( + pubkey: &Pubkey, + account: &AccountSharedData, + token_program: &Pubkey, +) -> Option { + let token_account = + parse_token_account_for_rent_pending(token_program, account.data())?; if token_account.close_authority != COption::Some(RENT_PENDING_ATA_CLOSE_AUTHORITY) || token_account.is_native.is_some() @@ -465,7 +503,7 @@ pub fn try_get_rent_pending_ata_info( let expected_ata = derive_ata_with_token_program( &token_account.owner, &token_account.mint, - token_program_owner, + token_program, ); if expected_ata != *pubkey { return None; @@ -479,7 +517,7 @@ pub fn try_get_rent_pending_ata_info( Some(RentPendingAtaInfo { ata_pubkey: *pubkey, eata_pubkey, - token_program: *token_program_owner, + token_program: *token_program, wallet_owner: token_account.owner, mint: token_account.mint, amount: token_account.amount, @@ -816,6 +854,20 @@ mod tests { assert!(try_get_rent_pending_ata_info(&ata, &undelegating_account) .is_none()); + let mut scheduled_undelegating_account = account.clone(); + scheduled_undelegating_account.set_owner(Pubkey::new_unique()); + scheduled_undelegating_account.set_delegated(false); + scheduled_undelegating_account.set_undelegating(true); + let undelegating_info = try_get_undelegating_rent_pending_ata_info( + &ata, + &scheduled_undelegating_account, + ) + .expect("scheduled undelegating rent-pending ATA should be detected"); + assert_eq!(undelegating_info.ata_pubkey, ata); + assert_eq!(undelegating_info.wallet_owner, wallet_owner); + assert_eq!(undelegating_info.mint, mint); + assert_eq!(undelegating_info.amount, 9); + let mut not_delegated = account.clone(); not_delegated.set_delegated(false); assert!(try_get_rent_pending_ata_info(&ata, ¬_delegated).is_none()); diff --git a/magicblock-processor/src/executor/processing.rs b/magicblock-processor/src/executor/processing.rs index 5399145e4..1ed35074f 100644 --- a/magicblock-processor/src/executor/processing.rs +++ b/magicblock-processor/src/executor/processing.rs @@ -11,7 +11,10 @@ use magicblock_core::{ }, }, tls::ExecutionTlsStash, - token_programs::try_get_rent_pending_ata_info, + token_programs::{ + try_get_rent_pending_ata_info, + try_get_undelegating_rent_pending_ata_info, RentPendingAtaInfo, + }, }; use magicblock_metrics::metrics::{ FAILED_TRANSACTIONS_COUNT, TRANSACTION_COUNT, @@ -477,7 +480,7 @@ impl super::TransactionExecutor { )); break; }; - let Some(info) = try_get_rent_pending_ata_info(&pubkey, acc) else { + let Some(info) = created_rent_pending_ata_info(&pubkey, acc) else { executed.execution_details.status = Err(TransactionError::UnbalancedTransaction); logs.push(format!( @@ -537,6 +540,20 @@ fn transaction_balances( ) } +fn created_rent_pending_ata_info( + pubkey: &Pubkey, + acc: &AccountSharedData, +) -> Option { + try_get_rent_pending_ata_info(pubkey, acc).or_else(|| { + if !ExecutionTlsStash::has_recorded_rent_pending_ata_materialization( + pubkey, + ) { + return None; + } + try_get_undelegating_rent_pending_ata_info(pubkey, acc) + }) +} + fn signature_fee( txn: &SanitizedTransaction, lamports_per_signature: u64, @@ -545,3 +562,75 @@ fn signature_fee( .num_total_signatures() .saturating_mul(lamports_per_signature) } + +#[cfg(test)] +mod tests { + use magicblock_core::token_programs::{ + derive_ata, RENT_PENDING_ATA_CLOSE_AUTHORITY, TOKEN_PROGRAM_ID, + }; + use solana_account::{Account, WritableAccount}; + use solana_program::{program_option::COption, program_pack::Pack}; + use spl_token::state::{ + Account as SplAccount, AccountState as SplAccountState, + }; + + use super::*; + + fn rent_pending_ata_account( + wallet_owner: Pubkey, + mint: Pubkey, + amount: u64, + ) -> AccountSharedData { + let token_account = SplAccount { + mint, + owner: wallet_owner, + amount, + delegate: COption::None, + state: SplAccountState::Initialized, + is_native: COption::None, + delegated_amount: 0, + close_authority: COption::Some(RENT_PENDING_ATA_CLOSE_AUTHORITY), + }; + let mut data = vec![0; SplAccount::LEN]; + SplAccount::pack(token_account, &mut data).unwrap(); + let mut account = AccountSharedData::from(Account { + lamports: 1_000_000, + data, + owner: TOKEN_PROGRAM_ID, + executable: false, + rent_epoch: 0, + }); + account.set_delegated(true); + account + } + + #[test] + fn created_rent_pending_ata_accepts_recorded_undelegating_materialization() + { + ExecutionTlsStash::clear(); + let wallet_owner = Pubkey::new_unique(); + let mint = Pubkey::new_unique(); + let ata = derive_ata(&wallet_owner, &mint); + let active_account = rent_pending_ata_account(wallet_owner, mint, 9); + + assert!(created_rent_pending_ata_info(&ata, &active_account).is_some()); + + let mut undelegating_account = active_account.clone(); + undelegating_account.set_owner(Pubkey::new_unique()); + undelegating_account.set_delegated(false); + undelegating_account.set_undelegating(true); + assert!(created_rent_pending_ata_info(&ata, &undelegating_account) + .is_none()); + + ExecutionTlsStash::register_recorded_rent_pending_ata_materialization( + ata, + ); + let info = created_rent_pending_ata_info(&ata, &undelegating_account) + .expect("recorded undelegating rent-pending ATA should validate"); + assert_eq!(info.ata_pubkey, ata); + assert_eq!(info.wallet_owner, wallet_owner); + assert_eq!(info.mint, mint); + assert_eq!(info.amount, 9); + ExecutionTlsStash::clear(); + } +} diff --git a/programs/magicblock/src/magic_scheduled_base_intent.rs b/programs/magicblock/src/magic_scheduled_base_intent.rs index cc27ba4ae..43fd01cc2 100644 --- a/programs/magicblock/src/magic_scheduled_base_intent.rs +++ b/programs/magicblock/src/magic_scheduled_base_intent.rs @@ -5,6 +5,7 @@ use std::{ use magicblock_core::{ intent::{BaseActionCallback, CommittedAccount}, + tls::ExecutionTlsStash, token_programs::{ try_get_rent_pending_ata_info, RentPendingAtaMaterialization, EATA_PROGRAM_ID, TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID, @@ -106,6 +107,9 @@ impl<'a, 'ic, 'ix_data> ConstructionContext<'a, 'ic, 'ix_data> { ); return Err(InstructionError::IllegalOwner); }; + ExecutionTlsStash::register_recorded_rent_pending_ata_materialization( + info.ata_pubkey, + ); materializations.push(RentPendingAtaMaterialization { ata_pubkey: info.ata_pubkey, eata_pubkey: info.eata_pubkey, diff --git a/programs/magicblock/src/schedule_transactions/process_schedule_commit.rs b/programs/magicblock/src/schedule_transactions/process_schedule_commit.rs index 065312376..da7c4fad2 100644 --- a/programs/magicblock/src/schedule_transactions/process_schedule_commit.rs +++ b/programs/magicblock/src/schedule_transactions/process_schedule_commit.rs @@ -2,6 +2,7 @@ use std::collections::HashSet; use magicblock_core::{ intent::CommittedAccount, + tls::ExecutionTlsStash, token_programs::{ try_get_rent_pending_ata_info, RentPendingAtaMaterialization, }, @@ -246,6 +247,9 @@ pub(crate) fn process_schedule_commit( } if let Some(info) = rent_pending_info { + ExecutionTlsStash::register_recorded_rent_pending_ata_materialization( + info.ata_pubkey, + ); rent_pending_ata_materializations.push( RentPendingAtaMaterialization { ata_pubkey: info.ata_pubkey, From 2de9c3ff3c4795d40c365e7abca68b1580101dda Mon Sep 17 00:00:00 2001 From: Gabriele Picco Date: Wed, 8 Jul 2026 17:32:27 +0800 Subject: [PATCH 16/16] Charge rent for rent-pending eATA materialization --- docs/mimd-rent-pending-ephemeral-ata.md | 12 ++++-- magicblock-core/src/token_programs.rs | 10 ++++- .../src/magic_scheduled_base_intent.rs | 16 +++++-- .../process_schedule_commit_tests.rs | 42 ++++++++++++------- 4 files changed, 56 insertions(+), 24 deletions(-) diff --git a/docs/mimd-rent-pending-ephemeral-ata.md b/docs/mimd-rent-pending-ephemeral-ata.md index 4fdbef2c7..48182fbd3 100644 --- a/docs/mimd-rent-pending-ephemeral-ata.md +++ b/docs/mimd-rent-pending-ephemeral-ata.md @@ -205,7 +205,11 @@ matching rent-pending ATAs are not rejected merely because their current balance is zero unless they were newly created in the same transaction. No local creation charge is required in V1. Base materialization cost remains -charged when the account is committed or undelegated. +charged when the account is committed or undelegated. The per-ATA charge is the +base-layer rent-exempt balance of the eATA account (which the validator fronts +and never recovers, since the eATA is not closed at undelegation) plus a fixed +service fee (`RENT_PENDING_ATA_MATERIALIZATION_FEE_LAMPORTS`), so the validator +authority never incurs a net loss on materialization. ## Materialization Metadata @@ -249,8 +253,10 @@ account: 3. Require the delegated payer path. The payer must be delegated and the magic fee vault must be present, writable, and delegated. 4. Charge the delegated payer and credit lamports to the delegated vault for the - materialization cost in local state. Local ATA creation remains free; base - eATA materialization is not free. + materialization cost in local state: the eATA rent-exempt balance plus the + fixed service fee, per rent-pending ATA. Local ATA creation remains free; + base eATA materialization is not free, and the charge makes the validator + whole for the rent it fronts on base. 5. Reject duplicate references where the same final eATA appears more than once across the normal committed-account set and rent-pending metadata. diff --git a/magicblock-core/src/token_programs.rs b/magicblock-core/src/token_programs.rs index f36f63aa5..9ce9a4d83 100644 --- a/magicblock-core/src/token_programs.rs +++ b/magicblock-core/src/token_programs.rs @@ -33,6 +33,14 @@ pub const RENT_PENDING_ATA_CLOSE_AUTHORITY: Pubkey = pub const EPHEMERAL_ATA_LEN: usize = 80; const LEGACY_EPHEMERAL_ATA_LEN: usize = 72; +/// Rent-exempt balance of the base-layer eATA account created when a +/// rent-pending ATA is materialized. The validator fronts this rent and never +/// recovers it (the eATA is not closed at undelegation), so the +/// materialization charge must cover it. +pub fn eata_rent_exempt_balance() -> u64 { + Rent::default().minimum_balance(EPHEMERAL_ATA_LEN) +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RentPendingAtaMaterialization { pub ata_pubkey: Pubkey, @@ -635,7 +643,7 @@ impl From for Account { data.extend_from_slice(&[0; 7]); Account { - lamports: Rent::default().minimum_balance(data.len()), + lamports: eata_rent_exempt_balance(), data, owner: EATA_PROGRAM_ID, executable: false, diff --git a/programs/magicblock/src/magic_scheduled_base_intent.rs b/programs/magicblock/src/magic_scheduled_base_intent.rs index 43fd01cc2..9f794833f 100644 --- a/programs/magicblock/src/magic_scheduled_base_intent.rs +++ b/programs/magicblock/src/magic_scheduled_base_intent.rs @@ -7,8 +7,9 @@ use magicblock_core::{ intent::{BaseActionCallback, CommittedAccount}, tls::ExecutionTlsStash, token_programs::{ - try_get_rent_pending_ata_info, RentPendingAtaMaterialization, - EATA_PROGRAM_ID, TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID, + eata_rent_exempt_balance, try_get_rent_pending_ata_info, + RentPendingAtaMaterialization, EATA_PROGRAM_ID, TOKEN_2022_PROGRAM_ID, + TOKEN_PROGRAM_ID, }, Slot, }; @@ -43,7 +44,8 @@ pub const ACTUAL_COMMIT_LIMIT: u64 = 25; /// Fixed fee per commit. /// https://github.com/magicblock-labs/delegation-program/blob/main/src/consts.rs#L11 pub const COMMIT_FEE_LAMPORTS: u64 = 100_000; -/// Fixed V1 fee per rent-pending eATA materialization. +/// Fixed V1 service fee per rent-pending eATA materialization, charged on top +/// of the base-layer rent the validator fronts to create the eATA account. pub const RENT_PENDING_ATA_MATERIALIZATION_FEE_LAMPORTS: u64 = COMMIT_FEE_LAMPORTS; /// Price per compute unit for a BaseAction executed on Solana base chain, @@ -1310,8 +1312,14 @@ pub(crate) fn calculate_commit_fee( pub(crate) fn calculate_rent_pending_ata_materialization_fee( count: usize, ) -> Result { + // The validator fronts the base-layer rent for every eATA it materializes + // and never recovers it (the eATA is not closed at undelegation), so the + // delegated payer covers that rent in addition to the flat service fee. + let per_ata = eata_rent_exempt_balance() + .checked_add(RENT_PENDING_ATA_MATERIALIZATION_FEE_LAMPORTS) + .ok_or(InstructionError::ArithmeticOverflow)?; (count as u64) - .checked_mul(RENT_PENDING_ATA_MATERIALIZATION_FEE_LAMPORTS) + .checked_mul(per_ata) .ok_or(InstructionError::ArithmeticOverflow) } diff --git a/programs/magicblock/src/schedule_transactions/process_schedule_commit_tests.rs b/programs/magicblock/src/schedule_transactions/process_schedule_commit_tests.rs index ba1fa07a2..6e4c754f2 100644 --- a/programs/magicblock/src/schedule_transactions/process_schedule_commit_tests.rs +++ b/programs/magicblock/src/schedule_transactions/process_schedule_commit_tests.rs @@ -1,6 +1,7 @@ use std::collections::HashMap; use assert_matches::assert_matches; +use magicblock_core::token_programs::eata_rent_exempt_balance; use magicblock_magic_program_api::{ args::{ ActionArgs, BaseActionArgs, MagicIntentBundleArgs, ShortAccountMeta, @@ -1497,6 +1498,10 @@ mod tests { /// Helper: builds transaction accounts for a delegated-payer commit. /// Payer is delegated+writable; fee vault is delegated+writable. + /// Funds the delegated payer generously enough to cover commit fees + /// plus rent-inclusive rent-pending materialization charges. + const DELEGATED_PAYER_LAMPORTS: u64 = 10_000_000; + fn prepare_delegated_payer_transaction( payer: &Keypair, program: Pubkey, @@ -1512,8 +1517,11 @@ mod tests { let mut account_data = { let mut map = HashMap::new(); - let mut payer_acc = - AccountSharedData::new(1_000_000, 0, &system_program::id()); + let mut payer_acc = AccountSharedData::new( + DELEGATED_PAYER_LAMPORTS, + 0, + &system_program::id(), + ); payer_acc.set_delegated(true); map.insert(payer.pubkey(), payer_acc); @@ -1596,7 +1604,8 @@ mod tests { accounts .iter() .find(|a| { - a.lamports() == 1_000_000 - COMMIT_FEE_LAMPORTS && a.delegated() + a.lamports() == DELEGATED_PAYER_LAMPORTS - COMMIT_FEE_LAMPORTS + && a.delegated() }) .expect("payer should have been debited"); } @@ -1670,6 +1679,8 @@ mod tests { let mint = Pubkey::new_unique(); let ata = derive_ata(&wallet_owner, &mint); let eata = derive_eata(&wallet_owner, &mint); + let expected_fee = eata_rent_exempt_balance() + + RENT_PENDING_ATA_MATERIALIZATION_FEE_LAMPORTS; let (mut account_data, mut transaction_accounts) = prepare_delegated_payer_transaction( @@ -1703,18 +1714,14 @@ mod tests { processed_scheduled .iter() - .find(|a| { - a.lamports() == RENT_PENDING_ATA_MATERIALIZATION_FEE_LAMPORTS - && a.delegated() - }) + .find(|a| a.lamports() == expected_fee && a.delegated()) .expect( "fee vault should receive rent-pending materialization fee", ); processed_scheduled .iter() .find(|a| { - a.lamports() - == 1_000_000 - RENT_PENDING_ATA_MATERIALIZATION_FEE_LAMPORTS + a.lamports() == DELEGATED_PAYER_LAMPORTS - expected_fee && a.delegated() }) .expect("payer should be charged rent-pending materialization fee"); @@ -1751,8 +1758,9 @@ mod tests { let mint = Pubkey::new_unique(); let ata = derive_ata(&wallet_owner, &mint); let eata = derive_eata(&wallet_owner, &mint); - let expected_fee = - COMMIT_FEE_LAMPORTS + RENT_PENDING_ATA_MATERIALIZATION_FEE_LAMPORTS; + let expected_fee = COMMIT_FEE_LAMPORTS + + eata_rent_exempt_balance() + + RENT_PENDING_ATA_MATERIALIZATION_FEE_LAMPORTS; let mut per_account = HashMap::new(); per_account.insert(eata, ACTUAL_COMMIT_LIMIT); @@ -1792,7 +1800,10 @@ mod tests { .expect("fee vault should receive materialization and commit fees"); processed_scheduled .iter() - .find(|a| a.lamports() == 1_000_000 - expected_fee && a.delegated()) + .find(|a| { + a.lamports() == DELEGATED_PAYER_LAMPORTS - expected_fee + && a.delegated() + }) .expect("payer should be charged materialization and commit fees"); } @@ -1844,10 +1855,9 @@ mod tests { assert_eq!(vault.lamports(), COMMIT_FEE_LAMPORTS); // Payer debited by exactly one fee - assert!(accounts - .iter() - .any(|a| a.lamports() == 1_000_000 - COMMIT_FEE_LAMPORTS - && a.delegated())); + assert!(accounts.iter().any(|a| a.lamports() + == DELEGATED_PAYER_LAMPORTS - COMMIT_FEE_LAMPORTS + && a.delegated())); } #[test]