Skip to main content

near_kit/types/
action.rs

1//! Transaction action types.
2
3use std::collections::BTreeMap;
4
5use base64::{Engine as _, engine::general_purpose::STANDARD};
6use borsh::{BorshDeserialize, BorshSerialize};
7use serde::{Deserialize, Serialize};
8use serde_with::base64::Base64;
9use serde_with::serde_as;
10use sha3::{Digest, Keccak256};
11
12use super::{AccountId, CryptoHash, Gas, NearToken, PublicKey, Signature, TryIntoAccountId};
13
14/// Publish mode for global contracts.
15///
16/// Determines how a published contract will be identified in the global registry.
17#[derive(Clone, Copy, Debug, PartialEq, Eq)]
18pub enum PublishMode {
19    /// Contract is identified by the signer's account ID.
20    /// The signer can update the contract later.
21    Updatable,
22    /// Contract is identified by its code hash.
23    /// The contract cannot be updated after publishing.
24    Immutable,
25}
26
27/// Trait for types that can identify a global contract.
28///
29/// This allows `deploy_from` to accept either a `CryptoHash` (for immutable
30/// contracts) or an account ID string/`AccountId` (for publisher-updatable contracts).
31///
32/// # Panics
33///
34/// String-based implementations (`&str`, `String`, `&String`) panic if the string is not a
35/// valid NEAR account ID.
36pub trait GlobalContractRef {
37    fn into_identifier(self) -> GlobalContractIdentifier;
38}
39
40impl GlobalContractRef for CryptoHash {
41    fn into_identifier(self) -> GlobalContractIdentifier {
42        GlobalContractIdentifier::CodeHash(self)
43    }
44}
45
46impl GlobalContractRef for AccountId {
47    fn into_identifier(self) -> GlobalContractIdentifier {
48        GlobalContractIdentifier::AccountId(self)
49    }
50}
51
52impl GlobalContractRef for &AccountId {
53    fn into_identifier(self) -> GlobalContractIdentifier {
54        GlobalContractIdentifier::AccountId(self.clone())
55    }
56}
57
58impl GlobalContractRef for &str {
59    fn into_identifier(self) -> GlobalContractIdentifier {
60        let account_id: AccountId = self.try_into_account_id().expect("invalid account ID");
61        GlobalContractIdentifier::AccountId(account_id)
62    }
63}
64
65impl GlobalContractRef for String {
66    fn into_identifier(self) -> GlobalContractIdentifier {
67        let account_id: AccountId = self.try_into_account_id().expect("invalid account ID");
68        GlobalContractIdentifier::AccountId(account_id)
69    }
70}
71
72impl GlobalContractRef for &String {
73    fn into_identifier(self) -> GlobalContractIdentifier {
74        let account_id: AccountId = self
75            .as_str()
76            .try_into_account_id()
77            .expect("invalid account ID");
78        GlobalContractIdentifier::AccountId(account_id)
79    }
80}
81
82/// NEP-461 prefix for delegate actions (meta-transactions).
83/// Value: 2^30 + 366 = 1073742190
84///
85/// This prefix is prepended to DelegateAction when serializing for signing,
86/// ensuring delegate action signatures are always distinguishable from
87/// regular transaction signatures.
88pub const DELEGATE_ACTION_PREFIX: u32 = 1_073_742_190;
89
90/// Gas key information.
91///
92/// Gas keys are access keys with a prepaid balance to pay for gas costs.
93#[derive(
94    Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, BorshSerialize, BorshDeserialize,
95)]
96pub struct GasKeyInfo {
97    /// Prepaid gas balance in yoctoNEAR.
98    pub balance: NearToken,
99    /// Number of nonces allocated for this gas key.
100    pub num_nonces: u16,
101}
102
103/// Access key permission.
104///
105/// IMPORTANT: Variant order matters for Borsh serialization!
106/// NEAR Protocol defines: 0 = FunctionCall, 1 = FullAccess,
107/// 2 = GasKeyFunctionCall, 3 = GasKeyFullAccess
108#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, BorshSerialize, BorshDeserialize)]
109pub enum AccessKeyPermission {
110    /// Function call access with restrictions. (discriminant = 0)
111    FunctionCall(FunctionCallPermission),
112    /// Full access to the account. (discriminant = 1)
113    FullAccess,
114    /// Gas key with function call access. (discriminant = 2)
115    GasKeyFunctionCall(GasKeyInfo, FunctionCallPermission),
116    /// Gas key with full access. (discriminant = 3)
117    GasKeyFullAccess(GasKeyInfo),
118}
119
120impl AccessKeyPermission {
121    /// Create a function call permission.
122    pub fn function_call(
123        receiver_id: AccountId,
124        method_names: Vec<String>,
125        allowance: Option<NearToken>,
126    ) -> Self {
127        Self::FunctionCall(FunctionCallPermission {
128            allowance,
129            receiver_id,
130            method_names,
131        })
132    }
133
134    /// Create a full access permission.
135    pub fn full_access() -> Self {
136        Self::FullAccess
137    }
138}
139
140/// Function call access key permission details.
141#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, BorshSerialize, BorshDeserialize)]
142pub struct FunctionCallPermission {
143    /// Maximum amount this key can spend (None = unlimited).
144    pub allowance: Option<NearToken>,
145    /// Contract that can be called.
146    pub receiver_id: AccountId,
147    /// Methods that can be called (empty = all methods).
148    pub method_names: Vec<String>,
149}
150
151/// Access key attached to an account.
152#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, BorshSerialize, BorshDeserialize)]
153pub struct AccessKey {
154    /// Nonce for replay protection.
155    pub nonce: u64,
156    /// Permission level.
157    pub permission: AccessKeyPermission,
158}
159
160impl AccessKey {
161    /// Create a full access key.
162    pub fn full_access() -> Self {
163        Self {
164            nonce: 0,
165            permission: AccessKeyPermission::FullAccess,
166        }
167    }
168
169    /// Create a function call access key.
170    pub fn function_call(
171        receiver_id: AccountId,
172        method_names: Vec<String>,
173        allowance: Option<NearToken>,
174    ) -> Self {
175        Self {
176            nonce: 0,
177            permission: AccessKeyPermission::function_call(receiver_id, method_names, allowance),
178        }
179    }
180}
181
182/// A transaction action.
183///
184/// IMPORTANT: Variant order matters for Borsh serialization!
185/// The discriminants match NEAR Protocol specification:
186/// 0 = CreateAccount, 1 = DeployContract, 2 = FunctionCall, 3 = Transfer,
187/// 4 = Stake, 5 = AddKey, 6 = DeleteKey, 7 = DeleteAccount, 8 = Delegate,
188/// 9 = DeployGlobalContract, 10 = UseGlobalContract, 11 = DeterministicStateInit,
189/// 12 = TransferToGasKey, 13 = WithdrawFromGasKey
190#[derive(Clone, Debug, PartialEq, Eq, BorshSerialize, BorshDeserialize)]
191pub enum Action {
192    /// Create a new account. (discriminant = 0)
193    CreateAccount(CreateAccountAction),
194    /// Deploy contract code. (discriminant = 1)
195    DeployContract(DeployContractAction),
196    /// Call a contract function. (discriminant = 2)
197    FunctionCall(FunctionCallAction),
198    /// Transfer NEAR tokens. (discriminant = 3)
199    Transfer(TransferAction),
200    /// Stake NEAR for validation. (discriminant = 4)
201    Stake(StakeAction),
202    /// Add an access key. (discriminant = 5)
203    AddKey(AddKeyAction),
204    /// Delete an access key. (discriminant = 6)
205    DeleteKey(DeleteKeyAction),
206    /// Delete the account. (discriminant = 7)
207    DeleteAccount(DeleteAccountAction),
208    /// Delegate action (for meta-transactions). (discriminant = 8)
209    Delegate(Box<SignedDelegateAction>),
210    /// Publish a contract to global registry. (discriminant = 9)
211    DeployGlobalContract(DeployGlobalContractAction),
212    /// Deploy from a previously published global contract. (discriminant = 10)
213    UseGlobalContract(UseGlobalContractAction),
214    /// NEP-616: Deploy with deterministically derived account ID. (discriminant = 11)
215    DeterministicStateInit(DeterministicStateInitAction),
216    /// Transfer NEAR to a gas key. (discriminant = 12)
217    TransferToGasKey(TransferToGasKeyAction),
218    /// Withdraw NEAR from a gas key. (discriminant = 13)
219    WithdrawFromGasKey(WithdrawFromGasKeyAction),
220}
221
222/// Create a new account.
223#[derive(Clone, Debug, PartialEq, Eq, BorshSerialize, BorshDeserialize)]
224pub struct CreateAccountAction;
225
226/// Deploy contract code.
227#[derive(Clone, Debug, PartialEq, Eq, BorshSerialize, BorshDeserialize)]
228pub struct DeployContractAction {
229    /// WASM code to deploy.
230    pub code: Vec<u8>,
231}
232
233/// Call a contract function.
234#[derive(Clone, Debug, PartialEq, Eq, BorshSerialize, BorshDeserialize)]
235pub struct FunctionCallAction {
236    /// Method name to call.
237    pub method_name: String,
238    /// Arguments (JSON or Borsh encoded).
239    pub args: Vec<u8>,
240    /// Gas to attach.
241    pub gas: Gas,
242    /// NEAR tokens to attach.
243    pub deposit: NearToken,
244}
245
246/// Transfer NEAR tokens.
247#[derive(Clone, Debug, PartialEq, Eq, BorshSerialize, BorshDeserialize)]
248pub struct TransferAction {
249    /// Amount to transfer.
250    pub deposit: NearToken,
251}
252
253/// Stake NEAR for validation.
254#[derive(Clone, Debug, PartialEq, Eq, BorshSerialize, BorshDeserialize)]
255pub struct StakeAction {
256    /// Amount to stake.
257    pub stake: NearToken,
258    /// Validator public key.
259    pub public_key: PublicKey,
260}
261
262/// Add an access key.
263#[derive(Clone, Debug, PartialEq, Eq, BorshSerialize, BorshDeserialize)]
264pub struct AddKeyAction {
265    /// Public key to add.
266    pub public_key: PublicKey,
267    /// Access key details.
268    pub access_key: AccessKey,
269}
270
271/// Delete an access key.
272#[derive(Clone, Debug, PartialEq, Eq, BorshSerialize, BorshDeserialize)]
273pub struct DeleteKeyAction {
274    /// Public key to delete.
275    pub public_key: PublicKey,
276}
277
278/// Delete the account.
279#[derive(Clone, Debug, PartialEq, Eq, BorshSerialize, BorshDeserialize)]
280pub struct DeleteAccountAction {
281    /// Account to receive remaining balance.
282    pub beneficiary_id: AccountId,
283}
284
285// ============================================================================
286// Global Contract Actions
287// ============================================================================
288
289/// How a global contract is identified in the registry.
290///
291/// Global contracts can be referenced either by their code hash (immutable)
292/// or by the account that published them (updatable).
293#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, BorshSerialize, BorshDeserialize)]
294pub enum GlobalContractIdentifier {
295    /// Reference by code hash (32-byte SHA-256 hash of the WASM code).
296    /// This creates an immutable reference - the contract cannot be updated.
297    #[serde(rename = "hash")]
298    CodeHash(CryptoHash),
299    /// Reference by the account ID that published the contract.
300    /// The publisher can update the contract, and all users will get the new version.
301    #[serde(rename = "account_id")]
302    AccountId(AccountId),
303}
304
305/// Deploy mode for global contracts.
306///
307/// Determines how the contract will be identified in the global registry.
308#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, BorshSerialize, BorshDeserialize)]
309#[repr(u8)]
310pub enum GlobalContractDeployMode {
311    /// Contract is identified by its code hash (immutable).
312    /// Other accounts reference it by the hash.
313    CodeHash,
314    /// Contract is identified by the signer's account ID (updatable).
315    /// The signer can update the contract later.
316    AccountId,
317}
318
319/// Publish a contract to the global registry.
320///
321/// Global contracts are deployed once and can be referenced by multiple accounts,
322/// saving storage costs. The contract can be identified either by its code hash
323/// (immutable) or by the publishing account (updatable).
324#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, BorshSerialize, BorshDeserialize)]
325pub struct DeployGlobalContractAction {
326    /// The WASM code to publish.
327    pub code: Vec<u8>,
328    /// How the contract will be identified.
329    pub deploy_mode: GlobalContractDeployMode,
330}
331
332/// Deploy a contract from the global registry.
333///
334/// Instead of uploading the WASM code, this action references a previously
335/// published contract in the global registry.
336#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, BorshSerialize, BorshDeserialize)]
337pub struct UseGlobalContractAction {
338    /// Reference to the published contract.
339    pub contract_identifier: GlobalContractIdentifier,
340}
341
342// ============================================================================
343// NEP-616 Deterministic Account Actions
344// ============================================================================
345
346/// State initialization data for NEP-616 deterministic accounts.
347///
348/// The account ID is derived from: `"0s" + hex(keccak256(borsh(state_init))[12..32])`
349#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, BorshSerialize, BorshDeserialize)]
350#[repr(u8)]
351pub enum DeterministicAccountStateInit {
352    /// Version 1 of the state init format.
353    V1(DeterministicAccountStateInitV1),
354}
355
356/// Version 1 of deterministic account state initialization.
357#[serde_as]
358#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, BorshSerialize, BorshDeserialize)]
359pub struct DeterministicAccountStateInitV1 {
360    /// Reference to the contract code (from global registry).
361    pub code: GlobalContractIdentifier,
362    /// Initial key-value pairs to populate in the contract's storage.
363    /// Keys and values are Borsh-serialized bytes.
364    #[serde_as(as = "BTreeMap<Base64, Base64>")]
365    pub data: BTreeMap<Vec<u8>, Vec<u8>>,
366}
367
368/// Deploy a contract with a deterministically derived account ID (NEP-616).
369///
370/// This enables creating accounts where the account ID is derived from the
371/// contract code and initial state, making them predictable and reproducible.
372#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, BorshSerialize, BorshDeserialize)]
373pub struct DeterministicStateInitAction {
374    /// The state initialization data.
375    pub state_init: DeterministicAccountStateInit,
376    /// Amount to attach for storage costs.
377    pub deposit: NearToken,
378}
379
380impl DeterministicAccountStateInit {
381    /// Create a state init referencing a global contract by its code hash (immutable).
382    pub fn by_hash(code_hash: CryptoHash, data: BTreeMap<Vec<u8>, Vec<u8>>) -> Self {
383        Self::V1(DeterministicAccountStateInitV1 {
384            code: GlobalContractIdentifier::CodeHash(code_hash),
385            data,
386        })
387    }
388
389    /// Create a state init referencing a global contract by its publisher account (updatable).
390    pub fn by_publisher(publisher_id: AccountId, data: BTreeMap<Vec<u8>, Vec<u8>>) -> Self {
391        Self::V1(DeterministicAccountStateInitV1 {
392            code: GlobalContractIdentifier::AccountId(publisher_id),
393            data,
394        })
395    }
396
397    /// Derive the deterministic account ID from this state init.
398    ///
399    /// The account ID is derived as: `"0s" + hex(keccak256(borsh(state_init))[12..32])`
400    ///
401    /// This produces a 42-character account ID that:
402    /// - Starts with "0s" prefix (distinguishes from Ethereum implicit accounts "0x")
403    /// - Followed by 40 hex characters (20 bytes from the keccak256 hash)
404    ///
405    /// # Example
406    ///
407    /// ```rust
408    /// use near_kit::types::{DeterministicAccountStateInit, CryptoHash};
409    /// use std::collections::BTreeMap;
410    ///
411    /// let state_init = DeterministicAccountStateInit::by_hash(CryptoHash::default(), BTreeMap::new());
412    ///
413    /// let account_id = state_init.derive_account_id();
414    /// assert!(account_id.as_str().starts_with("0s"));
415    /// assert_eq!(account_id.as_str().len(), 42);
416    /// ```
417    pub fn derive_account_id(&self) -> AccountId {
418        // Borsh-serialize the state init
419        let serialized = borsh::to_vec(self).expect("StateInit serialization should not fail");
420
421        // Compute keccak256 hash
422        let hash = Keccak256::digest(&serialized);
423
424        // Take last 20 bytes (indices 12-32) of the hash
425        let suffix = &hash[12..32];
426
427        // Format as "0s" + hex
428        let account_str = format!("0s{}", hex::encode(suffix));
429
430        // This is a valid deterministic account ID by construction
431        account_str
432            .parse()
433            .expect("deterministic account ID should always be valid")
434    }
435}
436
437impl DeterministicStateInitAction {
438    /// Derive the deterministic account ID for this action.
439    ///
440    /// Convenience method that delegates to `DeterministicAccountStateInit::derive_account_id`.
441    pub fn derive_account_id(&self) -> AccountId {
442        self.state_init.derive_account_id()
443    }
444}
445
446/// Transfer NEAR to a gas key.
447#[derive(Clone, Debug, PartialEq, Eq, BorshSerialize, BorshDeserialize)]
448pub struct TransferToGasKeyAction {
449    /// Public key of the gas key to fund.
450    pub public_key: PublicKey,
451    /// Amount of NEAR to transfer.
452    pub deposit: NearToken,
453}
454
455/// Withdraw NEAR from a gas key.
456#[derive(Clone, Debug, PartialEq, Eq, BorshSerialize, BorshDeserialize)]
457pub struct WithdrawFromGasKeyAction {
458    /// Public key of the gas key to withdraw from.
459    pub public_key: PublicKey,
460    /// Amount of NEAR to withdraw.
461    pub amount: NearToken,
462}
463
464/// Delegate action for meta-transactions.
465#[derive(Clone, Debug, PartialEq, Eq, BorshSerialize, BorshDeserialize)]
466pub struct DelegateAction {
467    /// Sender of the delegate action.
468    pub sender_id: AccountId,
469    /// Receiver of the delegate action.
470    pub receiver_id: AccountId,
471    /// Actions to delegate.
472    pub actions: Vec<NonDelegateAction>,
473    /// Nonce for replay protection.
474    pub nonce: u64,
475    /// Maximum block height for the action.
476    pub max_block_height: u64,
477    /// Public key authorizing the delegate.
478    pub public_key: PublicKey,
479}
480
481/// Signed delegate action.
482#[derive(Clone, Debug, PartialEq, Eq, BorshSerialize, BorshDeserialize)]
483pub struct SignedDelegateAction {
484    /// The delegate action.
485    pub delegate_action: DelegateAction,
486    /// Signature over the delegate action.
487    pub signature: super::Signature,
488}
489
490/// Non-delegate action (for use within DelegateAction).
491///
492/// This is a newtype wrapper around Action that ensures the wrapped action
493/// is not a Delegate variant, since delegate actions cannot contain nested
494/// delegate actions.
495///
496/// The newtype wrapper serializes identically to the inner Action, preserving
497/// Borsh compatibility with near-primitives.
498#[derive(Clone, Debug, PartialEq, Eq, BorshSerialize, BorshDeserialize)]
499pub struct NonDelegateAction(Action);
500
501// Helper constructors for actions
502impl Action {
503    /// Create a CreateAccount action.
504    pub fn create_account() -> Self {
505        Self::CreateAccount(CreateAccountAction)
506    }
507
508    /// Create a DeployContract action.
509    pub fn deploy_contract(code: Vec<u8>) -> Self {
510        Self::DeployContract(DeployContractAction { code })
511    }
512
513    /// Create a FunctionCall action.
514    pub fn function_call(
515        method_name: impl Into<String>,
516        args: Vec<u8>,
517        gas: Gas,
518        deposit: NearToken,
519    ) -> Self {
520        Self::FunctionCall(FunctionCallAction {
521            method_name: method_name.into(),
522            args,
523            gas,
524            deposit,
525        })
526    }
527
528    /// Create a Transfer action.
529    pub fn transfer(deposit: NearToken) -> Self {
530        Self::Transfer(TransferAction { deposit })
531    }
532
533    /// Create a Stake action.
534    pub fn stake(stake: NearToken, public_key: PublicKey) -> Self {
535        Self::Stake(StakeAction { stake, public_key })
536    }
537
538    /// Create an AddKey action for full access.
539    pub fn add_full_access_key(public_key: PublicKey) -> Self {
540        Self::AddKey(AddKeyAction {
541            public_key,
542            access_key: AccessKey::full_access(),
543        })
544    }
545
546    /// Create an AddKey action for function call access.
547    pub fn add_function_call_key(
548        public_key: PublicKey,
549        receiver_id: AccountId,
550        method_names: Vec<String>,
551        allowance: Option<NearToken>,
552    ) -> Self {
553        Self::AddKey(AddKeyAction {
554            public_key,
555            access_key: AccessKey::function_call(receiver_id, method_names, allowance),
556        })
557    }
558
559    /// Create a DeleteKey action.
560    pub fn delete_key(public_key: PublicKey) -> Self {
561        Self::DeleteKey(DeleteKeyAction { public_key })
562    }
563
564    /// Create a DeleteAccount action.
565    pub fn delete_account(beneficiary_id: AccountId) -> Self {
566        Self::DeleteAccount(DeleteAccountAction { beneficiary_id })
567    }
568
569    /// Create a Delegate action from a signed delegate action.
570    pub fn delegate(signed_delegate: SignedDelegateAction) -> Self {
571        Self::Delegate(Box::new(signed_delegate))
572    }
573
574    /// Publish a contract to the global registry.
575    ///
576    /// Global contracts are deployed once and can be referenced by multiple accounts,
577    /// saving storage costs.
578    ///
579    /// # Arguments
580    ///
581    /// * `code` - The WASM code to publish
582    /// * `mode` - Whether the contract is updatable (by publisher) or immutable (by hash)
583    ///
584    /// # Example
585    ///
586    /// ```rust,ignore
587    /// // Publish updatable contract (identified by your account)
588    /// near.transaction("alice.near")
589    ///     .publish(wasm_code, PublishMode::Updatable)
590    ///     .send()
591    ///     .await?;
592    ///
593    /// // Publish immutable contract (identified by its hash)
594    /// near.transaction("alice.near")
595    ///     .publish(wasm_code, PublishMode::Immutable)
596    ///     .send()
597    ///     .await?;
598    /// ```
599    pub fn publish(code: Vec<u8>, mode: PublishMode) -> Self {
600        Self::DeployGlobalContract(DeployGlobalContractAction {
601            code,
602            deploy_mode: match mode {
603                PublishMode::Updatable => GlobalContractDeployMode::AccountId,
604                PublishMode::Immutable => GlobalContractDeployMode::CodeHash,
605            },
606        })
607    }
608
609    /// Deploy a contract from the global registry by code hash.
610    ///
611    /// References a previously published immutable contract.
612    pub fn deploy_from_hash(code_hash: CryptoHash) -> Self {
613        Self::UseGlobalContract(UseGlobalContractAction {
614            contract_identifier: GlobalContractIdentifier::CodeHash(code_hash),
615        })
616    }
617
618    /// Deploy a contract from the global registry by account ID.
619    ///
620    /// References a contract published by the given account.
621    /// The contract can be updated by the publisher.
622    pub fn deploy_from_account(account_id: AccountId) -> Self {
623        Self::UseGlobalContract(UseGlobalContractAction {
624            contract_identifier: GlobalContractIdentifier::AccountId(account_id),
625        })
626    }
627
628    /// Create a NEP-616 deterministic state init action.
629    ///
630    /// The account ID is derived from the state init data:
631    /// `"0s" + hex(keccak256(borsh(state_init))[12..32])`
632    pub fn state_init(state_init: DeterministicAccountStateInit, deposit: NearToken) -> Self {
633        Self::DeterministicStateInit(DeterministicStateInitAction {
634            state_init,
635            deposit,
636        })
637    }
638
639    /// Transfer NEAR to a gas key.
640    pub fn transfer_to_gas_key(public_key: PublicKey, deposit: NearToken) -> Self {
641        Self::TransferToGasKey(TransferToGasKeyAction {
642            public_key,
643            deposit,
644        })
645    }
646
647    /// Withdraw NEAR from a gas key.
648    pub fn withdraw_from_gas_key(public_key: PublicKey, amount: NearToken) -> Self {
649        Self::WithdrawFromGasKey(WithdrawFromGasKeyAction { public_key, amount })
650    }
651}
652
653impl DelegateAction {
654    /// Serialize the delegate action for signing.
655    ///
656    /// Per NEP-461, this prepends a u32 prefix (2^30 + 366) before the delegate action,
657    /// ensuring signed delegate actions are never identical to signed transactions.
658    ///
659    /// # Example
660    ///
661    /// ```rust,ignore
662    /// let bytes = delegate_action.serialize_for_signing();
663    /// let hash = CryptoHash::hash(&bytes);
664    /// let signature = signer.sign(hash.as_bytes()).await?;
665    /// ```
666    pub fn serialize_for_signing(&self) -> Vec<u8> {
667        let prefix_bytes = DELEGATE_ACTION_PREFIX.to_le_bytes();
668        let action_bytes =
669            borsh::to_vec(self).expect("delegate action serialization should never fail");
670
671        let mut result = Vec::with_capacity(prefix_bytes.len() + action_bytes.len());
672        result.extend_from_slice(&prefix_bytes);
673        result.extend_from_slice(&action_bytes);
674        result
675    }
676
677    /// Get the hash of this delegate action (for signing).
678    pub fn get_hash(&self) -> CryptoHash {
679        let bytes = self.serialize_for_signing();
680        CryptoHash::hash(&bytes)
681    }
682
683    /// Sign this delegate action and return a SignedDelegateAction.
684    pub fn sign(self, signature: Signature) -> SignedDelegateAction {
685        SignedDelegateAction {
686            delegate_action: self,
687            signature,
688        }
689    }
690}
691
692impl SignedDelegateAction {
693    /// Encode the signed delegate action to bytes for transport.
694    pub fn to_bytes(&self) -> Vec<u8> {
695        borsh::to_vec(self).expect("signed delegate action serialization should never fail")
696    }
697
698    /// Encode the signed delegate action to base64 for transport.
699    ///
700    /// This is the most common format for sending delegate actions via HTTP/JSON.
701    pub fn to_base64(&self) -> String {
702        STANDARD.encode(self.to_bytes())
703    }
704
705    /// Decode a signed delegate action from bytes.
706    pub fn from_bytes(bytes: &[u8]) -> Result<Self, borsh::io::Error> {
707        borsh::from_slice(bytes)
708    }
709
710    /// Decode a signed delegate action from base64.
711    pub fn from_base64(s: &str) -> Result<Self, DecodeError> {
712        let bytes = STANDARD.decode(s).map_err(DecodeError::Base64)?;
713        Self::from_bytes(&bytes).map_err(DecodeError::Borsh)
714    }
715
716    /// Get the sender account ID.
717    pub fn sender_id(&self) -> &AccountId {
718        &self.delegate_action.sender_id
719    }
720
721    /// Get the receiver account ID.
722    pub fn receiver_id(&self) -> &AccountId {
723        &self.delegate_action.receiver_id
724    }
725}
726
727/// Error decoding a signed delegate action.
728#[derive(Debug, thiserror::Error)]
729pub enum DecodeError {
730    /// Base64 decoding failed.
731    #[error("base64 decode error: {0}")]
732    Base64(#[from] base64::DecodeError),
733    /// Borsh deserialization failed.
734    #[error("borsh decode error: {0}")]
735    Borsh(#[from] borsh::io::Error),
736}
737
738impl NonDelegateAction {
739    /// Convert from an Action, returning None if it's a Delegate action.
740    pub fn from_action(action: Action) -> Option<Self> {
741        if matches!(action, Action::Delegate(_)) {
742            None
743        } else {
744            Some(Self(action))
745        }
746    }
747
748    /// Get a reference to the inner action.
749    pub fn inner(&self) -> &Action {
750        &self.0
751    }
752
753    /// Consume self and return the inner action.
754    pub fn into_inner(self) -> Action {
755        self.0
756    }
757}
758
759impl From<NonDelegateAction> for Action {
760    fn from(action: NonDelegateAction) -> Self {
761        action.0
762    }
763}
764
765impl TryFrom<Action> for NonDelegateAction {
766    type Error = ();
767
768    fn try_from(action: Action) -> Result<Self, Self::Error> {
769        Self::from_action(action).ok_or(())
770    }
771}
772
773#[cfg(test)]
774mod tests {
775    use super::*;
776    use crate::types::{Gas, NearToken, SecretKey};
777
778    fn create_test_delegate_action() -> DelegateAction {
779        let sender_id: AccountId = "alice.testnet".parse().unwrap();
780        let receiver_id: AccountId = "bob.testnet".parse().unwrap();
781        let public_key: PublicKey = "ed25519:6E8sCci9badyRkXb3JoRpBj5p8C6Tw41ELDZoiihKEtp"
782            .parse()
783            .unwrap();
784
785        DelegateAction {
786            sender_id,
787            receiver_id,
788            actions: vec![
789                NonDelegateAction::from_action(Action::Transfer(TransferAction {
790                    deposit: NearToken::from_near(1),
791                }))
792                .unwrap(),
793            ],
794            nonce: 1,
795            max_block_height: 1000,
796            public_key,
797        }
798    }
799
800    #[test]
801    fn test_delegate_action_prefix() {
802        // NEP-461: prefix = 2^30 + 366
803        assert_eq!(DELEGATE_ACTION_PREFIX, 1073742190);
804        assert_eq!(DELEGATE_ACTION_PREFIX, (1 << 30) + 366);
805    }
806
807    #[test]
808    fn test_delegate_action_serialize_for_signing() {
809        let delegate_action = create_test_delegate_action();
810        let bytes = delegate_action.serialize_for_signing();
811
812        // First 4 bytes should be the NEP-461 prefix in little-endian
813        let prefix_bytes = &bytes[0..4];
814        let prefix = u32::from_le_bytes(prefix_bytes.try_into().unwrap());
815        assert_eq!(prefix, DELEGATE_ACTION_PREFIX);
816
817        // Rest should be borsh-serialized DelegateAction
818        let action_bytes = &bytes[4..];
819        let expected_action_bytes = borsh::to_vec(&delegate_action).unwrap();
820        assert_eq!(action_bytes, expected_action_bytes.as_slice());
821    }
822
823    #[test]
824    fn test_delegate_action_get_hash() {
825        let delegate_action = create_test_delegate_action();
826        let hash = delegate_action.get_hash();
827
828        // Hash should be SHA-256 of serialize_for_signing bytes
829        let bytes = delegate_action.serialize_for_signing();
830        let expected_hash = CryptoHash::hash(&bytes);
831        assert_eq!(hash, expected_hash);
832    }
833
834    #[test]
835    fn test_signed_delegate_action_roundtrip_bytes() {
836        let delegate_action = create_test_delegate_action();
837        let secret_key = SecretKey::generate_ed25519();
838        let hash = delegate_action.get_hash();
839        let signature = secret_key.sign(hash.as_bytes());
840        let signed = delegate_action.sign(signature);
841
842        // Roundtrip through bytes
843        let bytes = signed.to_bytes();
844        let decoded = SignedDelegateAction::from_bytes(&bytes).unwrap();
845
846        assert_eq!(decoded.sender_id().as_str(), signed.sender_id().as_str());
847        assert_eq!(
848            decoded.receiver_id().as_str(),
849            signed.receiver_id().as_str()
850        );
851        assert_eq!(decoded.delegate_action.nonce, signed.delegate_action.nonce);
852        assert_eq!(
853            decoded.delegate_action.max_block_height,
854            signed.delegate_action.max_block_height
855        );
856    }
857
858    #[test]
859    fn test_signed_delegate_action_roundtrip_base64() {
860        let delegate_action = create_test_delegate_action();
861        let secret_key = SecretKey::generate_ed25519();
862        let hash = delegate_action.get_hash();
863        let signature = secret_key.sign(hash.as_bytes());
864        let signed = delegate_action.sign(signature);
865
866        // Roundtrip through base64
867        let base64 = signed.to_base64();
868        let decoded = SignedDelegateAction::from_base64(&base64).unwrap();
869
870        assert_eq!(decoded.sender_id().as_str(), signed.sender_id().as_str());
871        assert_eq!(
872            decoded.receiver_id().as_str(),
873            signed.receiver_id().as_str()
874        );
875    }
876
877    #[test]
878    fn test_signed_delegate_action_accessors() {
879        let delegate_action = create_test_delegate_action();
880        let secret_key = SecretKey::generate_ed25519();
881        let hash = delegate_action.get_hash();
882        let signature = secret_key.sign(hash.as_bytes());
883        let signed = delegate_action.sign(signature);
884
885        assert_eq!(signed.sender_id().as_str(), "alice.testnet");
886        assert_eq!(signed.receiver_id().as_str(), "bob.testnet");
887    }
888
889    #[test]
890    fn test_non_delegate_action_from_action() {
891        // Transfer should convert
892        let transfer = Action::Transfer(TransferAction {
893            deposit: NearToken::from_near(1),
894        });
895        assert!(NonDelegateAction::from_action(transfer).is_some());
896
897        // FunctionCall should convert
898        let call = Action::FunctionCall(FunctionCallAction {
899            method_name: "test".to_string(),
900            args: vec![],
901            gas: Gas::default(),
902            deposit: NearToken::ZERO,
903        });
904        assert!(NonDelegateAction::from_action(call).is_some());
905
906        // Delegate should NOT convert (returns None)
907        let delegate_action = create_test_delegate_action();
908        let secret_key = SecretKey::generate_ed25519();
909        let hash = delegate_action.get_hash();
910        let signature = secret_key.sign(hash.as_bytes());
911        let signed = delegate_action.sign(signature);
912        let delegate = Action::delegate(signed);
913        assert!(NonDelegateAction::from_action(delegate).is_none());
914    }
915
916    #[test]
917    fn test_decode_error_display() {
918        // Test that DecodeError has proper Display impl
919        let base64_err = DecodeError::Base64(base64::DecodeError::InvalidLength(5));
920        assert!(format!("{}", base64_err).contains("base64"));
921
922        // Borsh error is harder to construct, but we tested the variant exists
923    }
924
925    // ========================================================================
926    // Global Contract Action Tests
927    // ========================================================================
928
929    #[test]
930    fn test_action_discriminants() {
931        // Verify action discriminants match NEAR protocol specification
932        // 0 = CreateAccount, 1 = DeployContract, 2 = FunctionCall, 3 = Transfer,
933        // 4 = Stake, 5 = AddKey, 6 = DeleteKey, 7 = DeleteAccount, 8 = Delegate,
934        // 9 = DeployGlobalContract, 10 = UseGlobalContract, 11 = DeterministicStateInit,
935        // 12 = TransferToGasKey, 13 = WithdrawFromGasKey
936
937        let create_account = Action::create_account();
938        let bytes = borsh::to_vec(&create_account).unwrap();
939        assert_eq!(bytes[0], 0, "CreateAccount should have discriminant 0");
940
941        let deploy = Action::deploy_contract(vec![1, 2, 3]);
942        let bytes = borsh::to_vec(&deploy).unwrap();
943        assert_eq!(bytes[0], 1, "DeployContract should have discriminant 1");
944
945        let transfer = Action::transfer(NearToken::from_near(1));
946        let bytes = borsh::to_vec(&transfer).unwrap();
947        assert_eq!(bytes[0], 3, "Transfer should have discriminant 3");
948
949        // DeployGlobalContract (discriminant = 9)
950        let publish = Action::publish(vec![1, 2, 3], PublishMode::Updatable);
951        let bytes = borsh::to_vec(&publish).unwrap();
952        assert_eq!(
953            bytes[0], 9,
954            "DeployGlobalContract should have discriminant 9"
955        );
956
957        // UseGlobalContract (discriminant = 10)
958        let code_hash = CryptoHash::hash(&[1, 2, 3]);
959        let use_global = Action::deploy_from_hash(code_hash);
960        let bytes = borsh::to_vec(&use_global).unwrap();
961        assert_eq!(
962            bytes[0], 10,
963            "UseGlobalContract should have discriminant 10"
964        );
965
966        // DeterministicStateInit (discriminant = 11)
967        let state_init = Action::state_init(
968            DeterministicAccountStateInit::by_hash(code_hash, BTreeMap::new()),
969            NearToken::from_near(1),
970        );
971        let bytes = borsh::to_vec(&state_init).unwrap();
972        assert_eq!(
973            bytes[0], 11,
974            "DeterministicStateInit should have discriminant 11"
975        );
976
977        // TransferToGasKey (discriminant = 12)
978        let pk: PublicKey = "ed25519:6E8sCci9badyRkXb3JoRpBj5p8C6Tw41ELDZoiihKEtp"
979            .parse()
980            .unwrap();
981        let transfer_gas = Action::transfer_to_gas_key(pk.clone(), NearToken::from_near(1));
982        let bytes = borsh::to_vec(&transfer_gas).unwrap();
983        assert_eq!(bytes[0], 12, "TransferToGasKey should have discriminant 12");
984
985        // WithdrawFromGasKey (discriminant = 13)
986        let withdraw_gas = Action::withdraw_from_gas_key(pk, NearToken::from_near(1));
987        let bytes = borsh::to_vec(&withdraw_gas).unwrap();
988        assert_eq!(
989            bytes[0], 13,
990            "WithdrawFromGasKey should have discriminant 13"
991        );
992    }
993
994    #[test]
995    fn test_global_contract_deploy_mode_serialization() {
996        // Verify deploy mode serialization
997        let by_hash = GlobalContractDeployMode::CodeHash;
998        let bytes = borsh::to_vec(&by_hash).unwrap();
999        assert_eq!(bytes, vec![0], "CodeHash mode should serialize to 0");
1000
1001        let by_account = GlobalContractDeployMode::AccountId;
1002        let bytes = borsh::to_vec(&by_account).unwrap();
1003        assert_eq!(bytes, vec![1], "AccountId mode should serialize to 1");
1004    }
1005
1006    #[test]
1007    fn test_global_contract_identifier_serialization() {
1008        // Verify identifier serialization
1009        let hash = CryptoHash::hash(&[1, 2, 3]);
1010        let by_hash = GlobalContractIdentifier::CodeHash(hash);
1011        let bytes = borsh::to_vec(&by_hash).unwrap();
1012        assert_eq!(
1013            bytes[0], 0,
1014            "CodeHash identifier should have discriminant 0"
1015        );
1016        assert_eq!(
1017            bytes.len(),
1018            1 + 32,
1019            "Should be 1 byte discriminant + 32 byte hash"
1020        );
1021
1022        let account_id: AccountId = "test.near".parse().unwrap();
1023        let by_account = GlobalContractIdentifier::AccountId(account_id);
1024        let bytes = borsh::to_vec(&by_account).unwrap();
1025        assert_eq!(
1026            bytes[0], 1,
1027            "AccountId identifier should have discriminant 1"
1028        );
1029    }
1030
1031    #[test]
1032    fn test_deploy_global_contract_action_roundtrip() {
1033        let code = vec![0, 97, 115, 109]; // WASM magic bytes
1034        let action = DeployGlobalContractAction {
1035            code: code.clone(),
1036            deploy_mode: GlobalContractDeployMode::CodeHash,
1037        };
1038
1039        let bytes = borsh::to_vec(&action).unwrap();
1040        let decoded: DeployGlobalContractAction = borsh::from_slice(&bytes).unwrap();
1041
1042        assert_eq!(decoded.code, code);
1043        assert_eq!(decoded.deploy_mode, GlobalContractDeployMode::CodeHash);
1044    }
1045
1046    #[test]
1047    fn test_use_global_contract_action_roundtrip() {
1048        let hash = CryptoHash::hash(&[1, 2, 3, 4]);
1049        let action = UseGlobalContractAction {
1050            contract_identifier: GlobalContractIdentifier::CodeHash(hash),
1051        };
1052
1053        let bytes = borsh::to_vec(&action).unwrap();
1054        let decoded: UseGlobalContractAction = borsh::from_slice(&bytes).unwrap();
1055
1056        assert_eq!(
1057            decoded.contract_identifier,
1058            GlobalContractIdentifier::CodeHash(hash)
1059        );
1060    }
1061
1062    #[test]
1063    fn test_deterministic_state_init_roundtrip() {
1064        let hash = CryptoHash::hash(&[1, 2, 3, 4]);
1065        let mut data = BTreeMap::new();
1066        data.insert(b"key1".to_vec(), b"value1".to_vec());
1067        data.insert(b"key2".to_vec(), b"value2".to_vec());
1068
1069        let action = DeterministicStateInitAction {
1070            state_init: DeterministicAccountStateInit::V1(DeterministicAccountStateInitV1 {
1071                code: GlobalContractIdentifier::CodeHash(hash),
1072                data: data.clone(),
1073            }),
1074            deposit: NearToken::from_near(5),
1075        };
1076
1077        let bytes = borsh::to_vec(&action).unwrap();
1078        let decoded: DeterministicStateInitAction = borsh::from_slice(&bytes).unwrap();
1079
1080        assert_eq!(decoded.deposit, NearToken::from_near(5));
1081        let DeterministicAccountStateInit::V1(v1) = decoded.state_init;
1082        assert_eq!(v1.code, GlobalContractIdentifier::CodeHash(hash));
1083        assert_eq!(v1.data, data);
1084    }
1085
1086    #[test]
1087    fn test_action_helper_constructors() {
1088        // Test publish
1089        let code = vec![1, 2, 3];
1090        let action = Action::publish(code.clone(), PublishMode::Immutable);
1091        if let Action::DeployGlobalContract(inner) = action {
1092            assert_eq!(inner.code, code);
1093            assert_eq!(inner.deploy_mode, GlobalContractDeployMode::CodeHash);
1094        } else {
1095            panic!("Expected DeployGlobalContract");
1096        }
1097
1098        let action = Action::publish(code.clone(), PublishMode::Updatable);
1099        if let Action::DeployGlobalContract(inner) = action {
1100            assert_eq!(inner.deploy_mode, GlobalContractDeployMode::AccountId);
1101        } else {
1102            panic!("Expected DeployGlobalContract");
1103        }
1104
1105        // Test deploy_from_hash
1106        let hash = CryptoHash::hash(&code);
1107        let action = Action::deploy_from_hash(hash);
1108        if let Action::UseGlobalContract(inner) = action {
1109            assert_eq!(
1110                inner.contract_identifier,
1111                GlobalContractIdentifier::CodeHash(hash)
1112            );
1113        } else {
1114            panic!("Expected UseGlobalContract");
1115        }
1116
1117        // Test deploy_from_account
1118        let account_id: AccountId = "publisher.near".parse().unwrap();
1119        let action = Action::deploy_from_account(account_id.clone());
1120        if let Action::UseGlobalContract(inner) = action {
1121            assert_eq!(
1122                inner.contract_identifier,
1123                GlobalContractIdentifier::AccountId(account_id)
1124            );
1125        } else {
1126            panic!("Expected UseGlobalContract");
1127        }
1128    }
1129
1130    #[test]
1131    fn test_derive_account_id_format() {
1132        // Test that derived account ID has the correct format
1133        let state_init = DeterministicAccountStateInit::V1(DeterministicAccountStateInitV1 {
1134            code: GlobalContractIdentifier::CodeHash(CryptoHash::default()),
1135            data: BTreeMap::new(),
1136        });
1137
1138        let account_id = state_init.derive_account_id();
1139        let account_str = account_id.as_str();
1140
1141        // Should start with "0s"
1142        assert!(
1143            account_str.starts_with("0s"),
1144            "Derived account should start with '0s', got: {}",
1145            account_str
1146        );
1147
1148        // Should be exactly 42 characters: "0s" + 40 hex chars
1149        assert_eq!(
1150            account_str.len(),
1151            42,
1152            "Derived account should be 42 chars, got: {}",
1153            account_str.len()
1154        );
1155
1156        // Everything after "0s" should be valid lowercase hex
1157        let hex_part = &account_str[2..];
1158        assert!(
1159            hex_part
1160                .chars()
1161                .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()),
1162            "Hex part should be lowercase hex, got: {}",
1163            hex_part
1164        );
1165    }
1166
1167    #[test]
1168    fn test_derive_account_id_deterministic() {
1169        // Same input should produce same output
1170        let state_init1 = DeterministicAccountStateInit::V1(DeterministicAccountStateInitV1 {
1171            code: GlobalContractIdentifier::AccountId("publisher.near".parse().unwrap()),
1172            data: BTreeMap::new(),
1173        });
1174
1175        let state_init2 = DeterministicAccountStateInit::V1(DeterministicAccountStateInitV1 {
1176            code: GlobalContractIdentifier::AccountId("publisher.near".parse().unwrap()),
1177            data: BTreeMap::new(),
1178        });
1179
1180        assert_eq!(
1181            state_init1.derive_account_id(),
1182            state_init2.derive_account_id(),
1183            "Same input should produce same account ID"
1184        );
1185    }
1186
1187    #[test]
1188    fn test_derive_account_id_different_inputs() {
1189        // Different code references should produce different account IDs
1190        let state_init1 = DeterministicAccountStateInit::V1(DeterministicAccountStateInitV1 {
1191            code: GlobalContractIdentifier::AccountId("publisher1.near".parse().unwrap()),
1192            data: BTreeMap::new(),
1193        });
1194
1195        let state_init2 = DeterministicAccountStateInit::V1(DeterministicAccountStateInitV1 {
1196            code: GlobalContractIdentifier::AccountId("publisher2.near".parse().unwrap()),
1197            data: BTreeMap::new(),
1198        });
1199
1200        assert_ne!(
1201            state_init1.derive_account_id(),
1202            state_init2.derive_account_id(),
1203            "Different code references should produce different account IDs"
1204        );
1205    }
1206
1207    #[test]
1208    fn test_access_key_permission_discriminants() {
1209        let fc = AccessKeyPermission::FunctionCall(FunctionCallPermission {
1210            allowance: None,
1211            receiver_id: "test.near".parse().unwrap(),
1212            method_names: vec![],
1213        });
1214        let bytes = borsh::to_vec(&fc).unwrap();
1215        assert_eq!(bytes[0], 0, "FunctionCall should have discriminant 0");
1216
1217        let fa = AccessKeyPermission::FullAccess;
1218        let bytes = borsh::to_vec(&fa).unwrap();
1219        assert_eq!(bytes[0], 1, "FullAccess should have discriminant 1");
1220
1221        let gkfc = AccessKeyPermission::GasKeyFunctionCall(
1222            GasKeyInfo {
1223                balance: NearToken::from_near(1),
1224                num_nonces: 5,
1225            },
1226            FunctionCallPermission {
1227                allowance: None,
1228                receiver_id: "test.near".parse().unwrap(),
1229                method_names: vec![],
1230            },
1231        );
1232        let bytes = borsh::to_vec(&gkfc).unwrap();
1233        assert_eq!(bytes[0], 2, "GasKeyFunctionCall should have discriminant 2");
1234
1235        let gkfa = AccessKeyPermission::GasKeyFullAccess(GasKeyInfo {
1236            balance: NearToken::from_near(1),
1237            num_nonces: 5,
1238        });
1239        let bytes = borsh::to_vec(&gkfa).unwrap();
1240        assert_eq!(bytes[0], 3, "GasKeyFullAccess should have discriminant 3");
1241    }
1242
1243    #[test]
1244    fn test_derive_account_id_different_data() {
1245        // Different data should produce different account IDs
1246        let mut data = BTreeMap::new();
1247        data.insert(b"key".to_vec(), b"value".to_vec());
1248
1249        let state_init1 = DeterministicAccountStateInit::V1(DeterministicAccountStateInitV1 {
1250            code: GlobalContractIdentifier::AccountId("publisher.near".parse().unwrap()),
1251            data: BTreeMap::new(),
1252        });
1253
1254        let state_init2 = DeterministicAccountStateInit::V1(DeterministicAccountStateInitV1 {
1255            code: GlobalContractIdentifier::AccountId("publisher.near".parse().unwrap()),
1256            data,
1257        });
1258
1259        assert_ne!(
1260            state_init1.derive_account_id(),
1261            state_init2.derive_account_id(),
1262            "Different data should produce different account IDs"
1263        );
1264    }
1265
1266    // ========================================================================
1267    // Deterministic Types JSON Serialization Tests
1268    // ========================================================================
1269
1270    #[test]
1271    fn test_deterministic_state_init_json_roundtrip() {
1272        // Build a DeterministicAccountStateInit with non-trivial data
1273        let hash = CryptoHash::hash(&[1, 2, 3, 4]);
1274        let mut data = BTreeMap::new();
1275        data.insert(b"key1".to_vec(), b"value1".to_vec());
1276        data.insert(b"key2".to_vec(), b"value2".to_vec());
1277
1278        let state_init = DeterministicAccountStateInit::V1(DeterministicAccountStateInitV1 {
1279            code: GlobalContractIdentifier::CodeHash(hash),
1280            data: data.clone(),
1281        });
1282
1283        // Serialize to JSON
1284        let json = serde_json::to_value(&state_init).unwrap();
1285
1286        // Verify externally-tagged format: {"V1": {...}} (matching nearcore)
1287        assert!(
1288            json.get("V1").is_some(),
1289            "Expected externally-tagged 'V1' key, got: {json}"
1290        );
1291        let v1 = json.get("V1").unwrap();
1292        assert!(v1.get("code").is_some(), "Expected 'code' field in V1");
1293        assert!(v1.get("data").is_some(), "Expected 'data' field in V1");
1294
1295        // Verify data keys/values are base64-encoded
1296        let data_obj = v1.get("data").unwrap().as_object().unwrap();
1297        // "key1" in base64 is "a2V5MQ=="
1298        assert!(
1299            data_obj.contains_key("a2V5MQ=="),
1300            "Expected base64-encoded key 'a2V5MQ==', got keys: {:?}",
1301            data_obj.keys().collect::<Vec<_>>()
1302        );
1303
1304        // Round-trip back
1305        let deserialized: DeterministicAccountStateInit = serde_json::from_value(json).unwrap();
1306        let DeterministicAccountStateInit::V1(v1_decoded) = deserialized;
1307        assert_eq!(v1_decoded.code, GlobalContractIdentifier::CodeHash(hash));
1308        assert_eq!(v1_decoded.data, data);
1309    }
1310
1311    #[test]
1312    fn test_global_contract_identifier_json_roundtrip() {
1313        // CodeHash variant
1314        let hash = CryptoHash::hash(&[1, 2, 3]);
1315        let id = GlobalContractIdentifier::CodeHash(hash);
1316        let json = serde_json::to_string(&id).unwrap();
1317        let decoded: GlobalContractIdentifier = serde_json::from_str(&json).unwrap();
1318        assert_eq!(decoded, id);
1319
1320        // AccountId variant
1321        let account_id: AccountId = "test.near".parse().unwrap();
1322        let id = GlobalContractIdentifier::AccountId(account_id);
1323        let json = serde_json::to_string(&id).unwrap();
1324        let decoded: GlobalContractIdentifier = serde_json::from_str(&json).unwrap();
1325        assert_eq!(decoded, id);
1326    }
1327
1328    #[test]
1329    fn test_deterministic_state_init_action_json_roundtrip() {
1330        let action = DeterministicStateInitAction {
1331            state_init: DeterministicAccountStateInit::V1(DeterministicAccountStateInitV1 {
1332                code: GlobalContractIdentifier::AccountId("publisher.near".parse().unwrap()),
1333                data: BTreeMap::new(),
1334            }),
1335            deposit: NearToken::from_near(5),
1336        };
1337
1338        let json = serde_json::to_string(&action).unwrap();
1339        let decoded: DeterministicStateInitAction = serde_json::from_str(&json).unwrap();
1340        assert_eq!(decoded, action);
1341    }
1342}