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/.agents/context/crates/magicblock-committor-service.md b/.agents/context/crates/magicblock-committor-service.md index 9e5ce5c20..2bf42e9bd 100644 --- a/.agents/context/crates/magicblock-committor-service.md +++ b/.agents/context/crates/magicblock-committor-service.md @@ -205,6 +205,21 @@ 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. 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. +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. + ### 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`: @@ -225,6 +240,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/.agents/context/crates/magicblock-core.md b/.agents/context/crates/magicblock-core.md index 06f23deae..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. 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, 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 @@ -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..420a54aa5 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,19 @@ 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. +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. ### Task scheduling flow @@ -214,7 +226,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..ff603de3a 100644 --- a/.agents/specs/validator-specification.md +++ b/.agents/specs/validator-specification.md @@ -329,6 +329,56 @@ 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. 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 +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. + +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. Token-2022 rent-pending ATAs must include `ImmutableOwner` in addition to +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. +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 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..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", @@ -3939,6 +3940,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 +3974,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 +3985,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/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 new file mode 100644 index 000000000..48182fbd3 --- /dev/null +++ b/docs/mimd-rent-pending-ephemeral-ata.md @@ -0,0 +1,486 @@ +# 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 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)`. +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 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: + +- 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. 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 + +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: 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. + +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-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( ) { 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/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 b4f26fe1d..05b1547f9 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,16 @@ pub trait TaskInfoFetcher: Send + Sync + 'static { min_context_slot: u64, ) -> TaskInfoFetcherResult>; + /// 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; + /// Fetches current commit nonces for pubkeys /// Missing nonces will be fetched from chain async fn fetch_current_commit_nonces( @@ -53,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], @@ -60,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 // --------------------------------------------------------------------------- @@ -118,6 +182,138 @@ 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(CommitNonceFetchResult::default()); + } + + 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()); + let mut missing_metadata = HashSet::new(); + 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) => { + missing_metadata.insert(delegated_pubkey); + 0 + } + None => { + return Err(TaskInfoFetcherError::AccountNotFoundError( + pda, + )); + } + }; + nonces.insert(delegated_pubkey, nonce); + } + + 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 pub async fn fetch_accounts_with_retries( rpc_client: &MagicblockRpcClient, @@ -172,6 +368,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 +470,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] @@ -230,19 +520,41 @@ impl TaskInfoFetcher for RpcTaskInfoFetcher { pubkeys: &[Pubkey], min_context_slot: u64, ) -> TaskInfoFetcherResult> { + self.fetch_next_commit_nonces_with_missing_as_zero( + pubkeys, + min_context_slot, + &[], + ) + .await + .map(|result| result.nonces) + } + + 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()); + return Ok(CommitNonceFetchResult::default()); } - let nonces = Self::fetch_metadata_with_retries( + + let result = 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(CommitNonceFetchResult { + nonces: result + .nonces + .into_iter() + .map(|(pubkey, nonce)| (pubkey, nonce + 1)) + .collect(), + missing_metadata: result.missing_metadata, + }) } async fn fetch_current_commit_nonces( @@ -270,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( @@ -298,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. @@ -329,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; @@ -403,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 @@ -446,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) }; @@ -502,8 +854,23 @@ impl TaskInfoFetcher for CacheTaskInfoFetcher { pubkeys: &[Pubkey], min_context_slot: u64, ) -> TaskInfoFetcherResult> { + self.fetch_next_commit_nonces_with_missing_as_zero( + pubkeys, + min_context_slot, + &[], + ) + .await + .map(|result| result.nonces) + } + + 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()); + return Ok(CommitNonceFetchResult::default()); } // Acquire locks on requested nonces @@ -515,49 +882,69 @@ 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() && !guard.missing_metadata { + existing.push((pubkey, guard)); } else { - existing.push((pubkey, guard)) + missing.push((pubkey, guard)) } } // 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()); 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); + } } - return Ok(result); + return Ok(CommitNonceFetchResult { + nonces, + missing_metadata: HashSet::new(), + }); } // Fetch missing nonces in cache - let fetched_nonces = { + let fetched_result = { 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? }; // 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); + } } for (pubkey, mut guard) in missing { - if let Some(&nonce) = fetched_nonces.get(pubkey) { - *guard = nonce + 1; - 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( @@ -577,10 +964,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)) } } @@ -602,7 +989,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 { @@ -623,6 +1010,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], @@ -911,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(); @@ -960,6 +1391,7 @@ mod tests { /// Fetcher mock struct MockInfoFetcher { nonces: Mutex>, + missing_metadata: Mutex>, delay: Option, } @@ -967,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 @@ -984,8 +1425,39 @@ 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 + .map(|result| result.nonces) + } + + 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(); + 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( @@ -1042,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 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..7046e9830 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,11 +18,15 @@ 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}; use solana_pubkey::Pubkey; +use solana_sdk_ids::system_program; pub mod commit_finalize_task; pub mod commit_task; @@ -45,6 +57,8 @@ pub enum TaskStrategy { #[derive(Clone, Debug)] pub enum BaseTaskImpl { + InitializeRentPendingAta(RentPendingAtaTask), + DelegateRentPendingAta(RentPendingAtaTask), Commit(CommitTask), CommitFinalize(CommitFinalizeTask), Finalize(FinalizeTask), @@ -54,11 +68,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 +107,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 +119,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 +139,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 +153,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 +179,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(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(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..7d43cb9e2 100644 --- a/magicblock-committor-service/src/tasks/task_builder.rs +++ b/magicblock-committor-service/src/tasks/task_builder.rs @@ -1,8 +1,13 @@ -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::RentPendingAtaMaterialization, +}; use magicblock_program::magic_scheduled_base_intent::{ BaseAction, CommitAndUndelegate, CommitType, ScheduledIntentBundle, UndelegateType, @@ -14,13 +19,14 @@ use tracing::error; use crate::{ intent_executor::task_info_fetcher::{ - TaskInfoFetcher, TaskInfoFetcherError, TaskInfoFetcherResult, + CommitNonceFetchResult, TaskInfoFetcher, TaskInfoFetcherError, + TaskInfoFetcherResult, }, persist::IntentPersister, tasks::{ commit_task::{CommitDelivery, CommitTask}, BaseActionTask, BaseActionTaskV1, BaseActionTaskV2, BaseTaskImpl, - CommitFinalizeTask, FinalizeTask, UndelegateTask, + CommitFinalizeTask, FinalizeTask, RentPendingAtaTask, UndelegateTask, }, }; @@ -30,6 +36,7 @@ pub trait TasksBuilder { async fn commit_tasks( commit_id_fetcher: &Arc, base_intent: &ScheduledIntentBundle, + validator: &Pubkey, persister: &Option

, ) -> TaskBuilderResult>; @@ -37,6 +44,7 @@ pub trait TasksBuilder { async fn finalize_tasks( info_fetcher: &Arc, base_intent: &ScheduledIntentBundle, + validator: &Pubkey, ) -> TaskBuilderResult>; } @@ -46,6 +54,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 @@ -108,14 +118,19 @@ impl TaskBuilderImpl { task_info_fetcher: &Arc, accounts: &[CommittedAccount], min_context_slot: u64, - ) -> TaskInfoFetcherResult> { + missing_metadata_as_zero: &[Pubkey], + ) -> TaskInfoFetcherResult { let committed_pubkeys = accounts .iter() .map(|account| account.pubkey) .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 } @@ -137,13 +152,36 @@ impl TaskBuilderImpl { .await } + 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, 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::>(); + // Get commit nonces and base accounts let min_context_slot = all_committed_accounts .iter() @@ -154,7 +192,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, @@ -162,8 +201,13 @@ impl TaskBuilderImpl { min_context_slot ) ); - let commit_nonces = + let commit_nonce_result = commit_ids.map_err(TaskBuilderError::CommitTasksBuildError)?; + let rent_pending_ata_materializations = intent_bundle + .intent_bundle + .rent_pending_ata_materializations + .clone(); + 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() @@ -181,6 +225,7 @@ impl TaskBuilderImpl { Ok(CommitStageTaskInfo { commit_nonces, base_accounts, + rent_pending_ata_materializations, }) } @@ -218,6 +263,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(); @@ -230,6 +276,7 @@ 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, @@ -237,6 +284,10 @@ impl TasksBuilder for TaskBuilderImpl { ) .await?; + 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 { tasks.extend( @@ -286,6 +337,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 { @@ -333,6 +385,7 @@ impl TasksBuilder for TaskBuilderImpl { async fn create_undelegate_tasks( commit_and_undelegate: &CommitAndUndelegate, info_fetcher: &Arc, + missing_metadata_reimbursements: &HashMap, ) -> TaskBuilderResult> { // Get rent reimbursments for undelegated accounts let accounts = commit_and_undelegate.get_committed_accounts(); @@ -346,17 +399,29 @@ impl TasksBuilder for TaskBuilderImpl { }) .collect::>(); let rent_reimbursements = info_fetcher - .fetch_rent_reimbursements(&pubkeys, min_context_slot) + .fetch_rent_reimbursements_with_missing_as( + &pubkeys, + min_context_slot, + missing_metadata_reimbursements, + ) .await .map_err(TaskBuilderError::FinalizedTasksBuildError)?; - let mut tasks = accounts - .iter() - .zip(rent_reimbursements) - .map(|(account, rent_reimbursement)| { - undelegate_task(account, &rent_reimbursement) - }) - .collect::>(); + let mut tasks = Vec::with_capacity(accounts.len()); + for account in accounts { + let rent_reimbursement = 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 @@ -367,6 +432,14 @@ impl TasksBuilder for TaskBuilderImpl { } let mut tasks = Vec::new(); + let missing_metadata_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 { @@ -377,13 +450,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, + &missing_metadata_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, + &missing_metadata_reimbursements, + )); } tasks.extend(try_join_all(futures).await?.into_iter().flatten()); @@ -541,3 +622,526 @@ impl TaskBuilderError { } pub type TaskBuilderResult = Result; + +#[cfg(test)] +mod tests { + use magicblock_core::token_programs::{ + try_derive_eata_address_and_bump, 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> { + self.fetch_next_commit_nonces_with_missing_as_zero( + pubkeys, + min_context_slot, + pubkeys, + ) + .await + .map(|result| result.nonces) + } + + async fn fetch_next_commit_nonces_with_missing_as_zero( + &self, + pubkeys: &[Pubkey], + _min_context_slot: u64, + missing_metadata_as_zero: &[Pubkey], + ) -> TaskInfoFetcherResult { + 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, + missing_metadata, + }) + } + + 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> { + assert!(pubkeys.is_empty()); + 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], + _min_context_slot: u64, + ) -> TaskInfoFetcherResult> { + assert!(pubkeys.is_empty()); + Ok(HashMap::new()) + } + } + + struct ExistingMetadataFetcher { + 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( + &self, + pubkeys: &[Pubkey], + min_context_slot: u64, + ) -> TaskInfoFetcherResult> { + self.fetch_next_commit_nonces_with_missing_as_zero( + pubkeys, + min_context_slot, + &[], + ) + .await + .map(|result| result.nonces) + } + + async fn fetch_next_commit_nonces_with_missing_as_zero( + &self, + pubkeys: &[Pubkey], + _min_context_slot: u64, + _missing_metadata_as_zero: &[Pubkey], + ) -> TaskInfoFetcherResult { + Ok(CommitNonceFetchResult::from_nonces( + 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> { + Ok(pubkeys + .iter() + .map(|_| existing_metadata_rent_reimbursement()) + .collect()) + } + + 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, + ) + } + + fn eata_intent_without_materialization_metadata( + 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); + let (intent, validator) = rent_pending_intent(false); + + 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 eata_commit_without_materialization_metadata_requires_base_metadata( + ) { + let fetcher = Arc::new(EmptyFetcher); + let (intent, validator) = + eata_intent_without_materialization_metadata(false); + + let err = TaskBuilderImpl::commit_tasks( + &fetcher, + &intent, + &validator, + &None::, + ) + .await + .unwrap_err(); + + assert!(matches!( + err, + TaskBuilderError::CommitTasksBuildError( + TaskInfoFetcherError::AccountNotFoundError(_) + ) + )); + } + + #[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, + &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.data[0], 4); + 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, &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 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 eata_undelegation_without_materialization_metadata_requires_base_reimbursement( + ) { + let fetcher = Arc::new(EmptyFetcher); + let (intent, validator) = + eata_intent_without_materialization_metadata(true); + + let err = + TaskBuilderImpl::finalize_tasks(&fetcher, &intent, &validator) + .await + .unwrap_err(); + + assert!(matches!( + err, + TaskBuilderError::FinalizedTasksBuildError( + TaskInfoFetcherError::AccountNotFoundError(_) + ) + )); + } + + #[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, + &validator, + &None::, + ) + .await + .unwrap(); + + let BaseTaskImpl::Commit(commit) = &tasks[2] else { + panic!("expected commit task"); + }; + assert_eq!(commit.commit_id, 2); + } + + #[tokio::test] + async fn eata_commit_existing_metadata_uses_normal_commit() { + let fetcher = Arc::new(ExistingMetadataFetcher { next_nonce: 2 }); + let (intent, validator) = + eata_intent_without_materialization_metadata(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( + ) { + 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, + &validator, + &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-committor-service/src/tasks/task_strategist.rs b/magicblock-committor-service/src/tasks/task_strategist.rs index b55cbd7dc..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::{ @@ -509,6 +509,17 @@ 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 + .map(CommitNonceFetchResult::from_nonces) + } + async fn fetch_current_commit_nonces( &self, pubkeys: &[Pubkey], @@ -868,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"); @@ -900,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"); @@ -937,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/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..cfe056b58 100644 --- a/magicblock-core/src/tls.rs +++ b/magicblock-core/src/tls.rs @@ -1,10 +1,13 @@ 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, + recorded_rent_pending_ata_materializations: VecDeque, // TODO(bmuddha/taco-paco): intents should go in here intents: VecDeque<()>, } @@ -23,9 +26,41 @@ 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 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 ad9210202..9ce9a4d83 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,52 @@ 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; +/// 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, + 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 +433,135 @@ 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 = account.owner(); + if !is_supported_token_program(token_program) { + return None; + } + try_get_rent_pending_ata_info_for_token_program( + pubkey, + account, + token_program, + ) +} + +/// 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() + { + 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, + &token_account.mint, + token_program, + ); + 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, + 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 @@ -470,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, @@ -638,4 +811,131 @@ 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()); + + 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()); + + 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-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..1ed35074f 100644 --- a/magicblock-processor/src/executor/processing.rs +++ b/magicblock-processor/src/executor/processing.rs @@ -11,6 +11,10 @@ use magicblock_core::{ }, }, tls::ExecutionTlsStash, + 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, @@ -458,6 +462,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) = created_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( @@ -501,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, @@ -509,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/magicblock-processor/tests/ephemeral_accounts.rs b/magicblock-processor/tests/ephemeral_accounts.rs index b072ca8f5..6386aaa0f 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,48 @@ 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 account = AccountSharedData::from(Account { + lamports: 1_000_000, + data, + owner: TOKEN_PROGRAM_ID, + executable: false, + rent_epoch: 0, + }); + 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 +192,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..11cda2cc4 --- /dev/null +++ b/programs/magicblock/src/ephemeral_accounts/process_create_rent_pending_ata.rs @@ -0,0 +1,687 @@ +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::{ + default_account_state::DefaultAccountState, BaseStateWithExtensions, + BaseStateWithExtensionsMut, ExtensionType, 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; + +struct TokenAccountShape { + len: usize, + required_extensions: Vec, + initial_state: Token2022AccountState, +} + +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); + } + // 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 + { + 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 token_account_shape = { + let mint_acc = mint_acc.borrow()?; + if mint_acc.owner() != &token_program { + return Err(InstructionError::InvalidAccountOwner); + } + 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(token_account_shape.len, 0); + initialize_token_data( + acc.data_as_mut_slice(), + &token_program, + wallet_owner, + mint, + &token_account_shape.required_extensions, + token_account_shape.initial_state, + )?; + 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_shape( + mint_data: &[u8], + token_program: &Pubkey, +) -> Result { + if *token_program == TOKEN_PROGRAM_ID { + SplMint::unpack(mint_data) + .map_err(|_| InstructionError::InvalidAccountData)?; + Ok(TokenAccountShape { + len: SplAccount::LEN, + required_extensions: Vec::new(), + initial_state: Token2022AccountState::Initialized, + }) + } else { + let mint = StateWithExtensions::::unpack(mint_data) + .map_err(|_| InstructionError::InvalidAccountData)?; + 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 mut required_extensions = + 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); + } + let len = ExtensionType::try_calculate_account_len::( + &required_extensions, + ) + .map_err(|_| InstructionError::InvalidAccountData)?; + Ok(TokenAccountShape { + len, + required_extensions, + initial_state, + }) + } +} + +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, + wallet_owner: Pubkey, + mint: Pubkey, + required_extensions: &[ExtensionType], + initial_state: Token2022AccountState, +) -> 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_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, + amount: 0, + delegate: COption::None, + state: initial_state, + is_native: COption::None, + delegated_amount: 0, + close_authority: COption::Some(RENT_PENDING_ATA_CLOSE_AUTHORITY), + }; + state.pack_base(); + state + .init_account_type() + .map_err(|_| InstructionError::InvalidAccountData) + } +} + +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 + }) +} + +#[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 spl_token_2022::extension::{ + immutable_owner::ImmutableOwner, + non_transferable::{NonTransferable, NonTransferableAccount}, + transfer_fee::TransferFeeConfig, + }; + + 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 + } + + 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::DefaultAccountState => { + let extension = state + .init_extension::(false) + .unwrap(); + extension.state = Token2022AccountState::Frozen.into(); + } + ExtensionType::NonTransferable => { + state.init_extension::(false).unwrap(); + } + ExtensionType::TransferFeeConfig => { + 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(); + 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) + ); + } + + #[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); + 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(); + 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) + ); + 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); + 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()); + } + + #[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(); + 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/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/programs/magicblock/src/magic_scheduled_base_intent.rs b/programs/magicblock/src/magic_scheduled_base_intent.rs index 88050bf62..9f794833f 100644 --- a/programs/magicblock/src/magic_scheduled_base_intent.rs +++ b/programs/magicblock/src/magic_scheduled_base_intent.rs @@ -1,9 +1,15 @@ -use std::collections::{HashMap, HashSet}; +use std::{ + cell::RefCell, + collections::{HashMap, HashSet}, +}; use magicblock_core::{ intent::{BaseActionCallback, CommittedAccount}, + tls::ExecutionTlsStash, token_programs::{ - 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, }; @@ -38,6 +44,10 @@ 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 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, /// denominated in micro-lamports per CU (mirrors Solana's priority fee model). pub const COMPUTE_UNIT_PRICE_MICRO_LAMPORTS: u64 = 50_000; @@ -51,6 +61,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 +71,61 @@ 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); + }; + ExecutionTlsStash::register_recorded_rent_pending_ata_materialization( + info.ata_pubkey, + ); + 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 +273,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 +338,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 +1049,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 +1075,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 +1309,20 @@ 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(per_ata) + .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 +1477,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/mod.rs b/programs/magicblock/src/schedule_transactions/mod.rs index b16b66a5a..13f0b2f23 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,40 @@ 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(nonces) => 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(fetched) => { + 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) +} + 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 a4a0f641e..da7c4fad2 100644 --- a/programs/magicblock/src/schedule_transactions/process_schedule_commit.rs +++ b/programs/magicblock/src/schedule_transactions/process_schedule_commit.rs @@ -1,6 +1,12 @@ use std::collections::HashSet; -use magicblock_core::intent::CommittedAccount; +use magicblock_core::{ + intent::CommittedAccount, + tls::ExecutionTlsStash, + 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 +16,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, + }, + schedule_transactions::{ + self, check_commit_limits, fetch_commit_nonces_with_missing_as_zero, + magic_fee_vault_pubkey, try_get_fee_vault, }, - magic_sys::fetch_current_commit_nonces, - schedule_transactions::{self, check_commit_limits, try_get_fee_vault}, utils::{ account_actions::{ charge_delegated_payer, mark_account_as_undelegated, @@ -26,6 +35,7 @@ use crate::{ }, instruction_utils::InstructionUtils, }, + validator::effective_validator_authority_id, MagicContext, }; @@ -142,6 +152,8 @@ 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 undelegated_accounts_ref = Vec::new(); let mut seen_committed_pubkeys: HashSet = HashSet::new(); for idx in committees_start..ix_accs_len { let acc_pubkey = @@ -203,6 +215,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,35 +246,46 @@ pub(crate) fn process_schedule_commit( continue; } + 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, + 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); } 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)); } } 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 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(), + )?) + .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. @@ -260,6 +293,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 @@ -295,7 +350,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 +361,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..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, @@ -23,6 +24,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 +283,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 +316,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() { @@ -1478,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, @@ -1493,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); @@ -1577,11 +1604,209 @@ 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"); } + #[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() { + 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 expected_fee = eata_rent_exempt_balance() + + RENT_PENDING_ATA_MATERIALIZATION_FEE_LAMPORTS; + + 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() == expected_fee && a.delegated()) + .expect( + "fee vault should receive rent-pending materialization fee", + ); + processed_scheduled + .iter() + .find(|a| { + a.lamports() == DELEGATED_PAYER_LAMPORTS - expected_fee + && 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_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 + + eata_rent_exempt_balance() + + 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() == DELEGATED_PAYER_LAMPORTS - 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() { @@ -1630,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] 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..9411beb82 100644 --- a/programs/magicblock/src/schedule_transactions/process_schedule_intent_bundle.rs +++ b/programs/magicblock/src/schedule_transactions/process_schedule_intent_bundle.rs @@ -9,11 +9,12 @@ 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::{ - 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::{ @@ -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. @@ -114,6 +137,49 @@ 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 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( + 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. @@ -134,25 +200,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)?; - let magic_fee_vault = try_get_fee_vault( - transaction_context, - invoke_context, - PAYER_IDX, - 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)?; - 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); diff --git a/test-integration/Cargo.lock b/test-integration/Cargo.lock index 04305a12b..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", @@ -4051,6 +4052,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 +4063,8 @@ dependencies = [ "solana-sysvar 3.1.1", "solana-transaction", "solana-transaction-context", + "spl-token-2022-interface", + "spl-token-interface", "thiserror 2.0.18", ] diff --git a/test-integration/test-committor-service/tests/common.rs b/test-integration/test-committor-service/tests/common.rs index 66f896211..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, }, @@ -176,6 +176,18 @@ 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 { + Ok(CommitNonceFetchResult { + nonces: self.fetch_next_commit_nonces(pubkeys, 0).await?, + missing_metadata: Default::default(), + }) + } + async fn fetch_current_commit_nonces( &self, pubkeys: &[Pubkey], 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);