Skip to main content

near_kit/client/
transaction.rs

1//! Transaction builder for fluent multi-action transactions.
2//!
3//! Allows chaining multiple actions (transfers, function calls, account creation, etc.)
4//! into a single atomic transaction. All actions either succeed together or fail together.
5//!
6//! # Example
7//!
8//! ```rust,no_run
9//! # use near_kit::*;
10//! # async fn example() -> Result<(), near_kit::Error> {
11//! let near = Near::testnet()
12//!     .credentials("ed25519:...", "alice.testnet")?
13//!     .build();
14//!
15//! // Create a new sub-account with funding and a key
16//! let new_public_key: PublicKey = "ed25519:6E8sCci9badyRkXb3JoRpBj5p8C6Tw41ELDZoiihKEtp".parse()?;
17//! let wasm_code = std::fs::read("contract.wasm").expect("failed to read wasm");
18//! near.transaction("new.alice.testnet")
19//!     .create_account()
20//!     .transfer(NearToken::from_near(5))
21//!     .add_full_access_key(new_public_key)
22//!     .deploy(wasm_code)
23//!     .call("init")
24//!         .args(serde_json::json!({ "owner": "alice.testnet" }))
25//!     .send()
26//!     .await?;
27//! # Ok(())
28//! # }
29//! ```
30
31use std::fmt;
32use std::future::{Future, IntoFuture};
33use std::pin::Pin;
34use std::sync::{Arc, OnceLock};
35
36use tracing::Instrument;
37
38use crate::error::{Error, RpcError};
39use crate::types::{
40    AccountId, Action, BlockReference, CryptoHash, DelegateAction, DeterministicAccountStateInit,
41    FinalExecutionOutcome, Finality, Gas, GlobalContractIdentifier, GlobalContractRef, IntoGas,
42    IntoNearToken, NearToken, NonDelegateAction, PublicKey, PublishMode, SignedDelegateAction,
43    SignedTransaction, Transaction, TryIntoAccountId, WaitLevel,
44};
45
46use super::nonce_manager::NonceManager;
47use super::rpc::RpcClient;
48use super::signer::Signer;
49
50/// Global nonce manager shared across all TransactionBuilder instances.
51/// This is an implementation detail - not exposed to users.
52fn nonce_manager() -> &'static NonceManager {
53    static NONCE_MANAGER: OnceLock<NonceManager> = OnceLock::new();
54    NONCE_MANAGER.get_or_init(NonceManager::new)
55}
56
57// ============================================================================
58// Delegate Action Types
59// ============================================================================
60
61/// Options for creating a delegate action (meta-transaction).
62#[derive(Clone, Debug, Default)]
63pub struct DelegateOptions {
64    /// Explicit block height at which the delegate action expires.
65    /// If omitted, uses the current block height plus `block_height_offset`.
66    pub max_block_height: Option<u64>,
67
68    /// Number of blocks after the current height when the delegate action should expire.
69    /// Defaults to 200 blocks if neither this nor `max_block_height` is provided.
70    pub block_height_offset: Option<u64>,
71
72    /// Override nonce to use for the delegate action. If omitted, fetches
73    /// from the access key and uses nonce + 1.
74    pub nonce: Option<u64>,
75}
76
77impl DelegateOptions {
78    /// Create options with a specific block height offset.
79    pub fn with_offset(offset: u64) -> Self {
80        Self {
81            block_height_offset: Some(offset),
82            ..Default::default()
83        }
84    }
85
86    /// Create options with a specific max block height.
87    pub fn with_max_height(height: u64) -> Self {
88        Self {
89            max_block_height: Some(height),
90            ..Default::default()
91        }
92    }
93}
94
95/// Result of creating a delegate action.
96///
97/// Contains the signed delegate action plus a pre-encoded payload for transport.
98#[derive(Clone, Debug)]
99pub struct DelegateResult {
100    /// The fully signed delegate action.
101    pub signed_delegate_action: SignedDelegateAction,
102    /// Base64-encoded payload for HTTP/JSON transport.
103    pub payload: String,
104}
105
106impl DelegateResult {
107    /// Get the raw bytes of the signed delegate action.
108    pub fn to_bytes(&self) -> Vec<u8> {
109        self.signed_delegate_action.to_bytes()
110    }
111
112    /// Get the sender account ID.
113    pub fn sender_id(&self) -> &AccountId {
114        self.signed_delegate_action.sender_id()
115    }
116
117    /// Get the receiver account ID.
118    pub fn receiver_id(&self) -> &AccountId {
119        self.signed_delegate_action.receiver_id()
120    }
121}
122
123// ============================================================================
124// TransactionBuilder
125// ============================================================================
126
127/// Builder for constructing multi-action transactions.
128///
129/// Created via [`crate::Near::transaction`]. Supports chaining multiple actions
130/// into a single atomic transaction.
131///
132/// # Example
133///
134/// ```rust,no_run
135/// # use near_kit::*;
136/// # async fn example() -> Result<(), near_kit::Error> {
137/// let near = Near::testnet()
138///     .credentials("ed25519:...", "alice.testnet")?
139///     .build();
140///
141/// // Single action
142/// near.transaction("bob.testnet")
143///     .transfer(NearToken::from_near(1))
144///     .send()
145///     .await?;
146///
147/// // Multiple actions (atomic)
148/// let key: PublicKey = "ed25519:6E8sCci9badyRkXb3JoRpBj5p8C6Tw41ELDZoiihKEtp".parse()?;
149/// near.transaction("new.alice.testnet")
150///     .create_account()
151///     .transfer(NearToken::from_near(5))
152///     .add_full_access_key(key)
153///     .send()
154///     .await?;
155/// # Ok(())
156/// # }
157/// ```
158pub struct TransactionBuilder {
159    rpc: Arc<RpcClient>,
160    signer: Option<Arc<dyn Signer>>,
161    receiver_id: AccountId,
162    actions: Vec<Action>,
163    signer_override: Option<Arc<dyn Signer>>,
164    max_nonce_retries: u32,
165}
166
167impl fmt::Debug for TransactionBuilder {
168    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
169        f.debug_struct("TransactionBuilder")
170            .field(
171                "signer_id",
172                &self
173                    .signer_override
174                    .as_ref()
175                    .or(self.signer.as_ref())
176                    .map(|s| s.account_id()),
177            )
178            .field("receiver_id", &self.receiver_id)
179            .field("action_count", &self.actions.len())
180            .field("max_nonce_retries", &self.max_nonce_retries)
181            .finish()
182    }
183}
184
185impl TransactionBuilder {
186    pub(crate) fn new(
187        rpc: Arc<RpcClient>,
188        signer: Option<Arc<dyn Signer>>,
189        receiver_id: AccountId,
190        max_nonce_retries: u32,
191    ) -> Self {
192        Self {
193            rpc,
194            signer,
195            receiver_id,
196            actions: Vec::new(),
197            signer_override: None,
198            max_nonce_retries,
199        }
200    }
201
202    // ========================================================================
203    // Action methods
204    // ========================================================================
205
206    /// Add a create account action.
207    ///
208    /// Creates a new sub-account. Must be followed by `transfer` and `add_key`
209    /// to properly initialize the account.
210    pub fn create_account(mut self) -> Self {
211        self.actions.push(Action::create_account());
212        self
213    }
214
215    /// Add a transfer action.
216    ///
217    /// Transfers NEAR tokens to the receiver account.
218    ///
219    /// # Example
220    ///
221    /// ```rust,no_run
222    /// # use near_kit::*;
223    /// # async fn example(near: Near) -> Result<(), near_kit::Error> {
224    /// near.transaction("bob.testnet")
225    ///     .transfer(NearToken::from_near(1))
226    ///     .send()
227    ///     .await?;
228    /// # Ok(())
229    /// # }
230    /// ```
231    ///
232    /// # Panics
233    ///
234    /// Panics if the amount string cannot be parsed.
235    pub fn transfer(mut self, amount: impl IntoNearToken) -> Self {
236        let amount = amount
237            .into_near_token()
238            .expect("invalid transfer amount - use NearToken::from_str() for user input");
239        self.actions.push(Action::transfer(amount));
240        self
241    }
242
243    /// Add a deploy contract action.
244    ///
245    /// Deploys WASM code to the receiver account.
246    pub fn deploy(mut self, code: impl Into<Vec<u8>>) -> Self {
247        self.actions.push(Action::deploy_contract(code.into()));
248        self
249    }
250
251    /// Add a function call action.
252    ///
253    /// Returns a [`CallBuilder`] for configuring the call with args, gas, and deposit.
254    ///
255    /// # Example
256    ///
257    /// ```rust,no_run
258    /// # use near_kit::*;
259    /// # async fn example(near: Near) -> Result<(), near_kit::Error> {
260    /// near.transaction("contract.testnet")
261    ///     .call("set_greeting")
262    ///         .args(serde_json::json!({ "greeting": "Hello" }))
263    ///         .gas(Gas::from_tgas(10))
264    ///         .deposit(NearToken::ZERO)
265    ///     .call("another_method")
266    ///         .args(serde_json::json!({ "value": 42 }))
267    ///     .send()
268    ///     .await?;
269    /// # Ok(())
270    /// # }
271    /// ```
272    pub fn call(self, method: &str) -> CallBuilder {
273        CallBuilder::new(self, method.to_string())
274    }
275
276    /// Add a full access key to the account.
277    pub fn add_full_access_key(mut self, public_key: PublicKey) -> Self {
278        self.actions.push(Action::add_full_access_key(public_key));
279        self
280    }
281
282    /// Add a function call access key to the account.
283    ///
284    /// # Arguments
285    ///
286    /// * `public_key` - The public key to add
287    /// * `receiver_id` - The contract this key can call
288    /// * `method_names` - Methods this key can call (empty = all methods)
289    /// * `allowance` - Maximum amount this key can spend (None = unlimited)
290    pub fn add_function_call_key(
291        mut self,
292        public_key: PublicKey,
293        receiver_id: impl TryIntoAccountId,
294        method_names: Vec<String>,
295        allowance: Option<NearToken>,
296    ) -> Self {
297        let receiver_id = receiver_id
298            .try_into_account_id()
299            .expect("invalid account ID");
300        self.actions.push(Action::add_function_call_key(
301            public_key,
302            receiver_id,
303            method_names,
304            allowance,
305        ));
306        self
307    }
308
309    /// Delete an access key from the account.
310    pub fn delete_key(mut self, public_key: PublicKey) -> Self {
311        self.actions.push(Action::delete_key(public_key));
312        self
313    }
314
315    /// Delete the account and transfer remaining balance to beneficiary.
316    pub fn delete_account(mut self, beneficiary_id: impl TryIntoAccountId) -> Self {
317        let beneficiary_id = beneficiary_id
318            .try_into_account_id()
319            .expect("invalid account ID");
320        self.actions.push(Action::delete_account(beneficiary_id));
321        self
322    }
323
324    /// Add a stake action.
325    ///
326    /// # Panics
327    ///
328    /// Panics if the amount string cannot be parsed.
329    pub fn stake(mut self, amount: impl IntoNearToken, public_key: PublicKey) -> Self {
330        let amount = amount
331            .into_near_token()
332            .expect("invalid stake amount - use NearToken::from_str() for user input");
333        self.actions.push(Action::stake(amount, public_key));
334        self
335    }
336
337    /// Add a signed delegate action to this transaction (for relayers).
338    ///
339    /// This is used by relayers to wrap a user's signed delegate action
340    /// and submit it to the blockchain, paying for the gas on behalf of the user.
341    ///
342    /// # Example
343    ///
344    /// ```rust,no_run
345    /// # use near_kit::*;
346    /// # async fn example(relayer: Near, payload: &str) -> Result<(), near_kit::Error> {
347    /// // Relayer receives base64 payload from user
348    /// let signed_delegate = SignedDelegateAction::from_base64(payload)?;
349    ///
350    /// // Relayer submits it, paying the gas
351    /// let result = relayer
352    ///     .transaction(signed_delegate.sender_id())
353    ///     .signed_delegate_action(signed_delegate)
354    ///     .send()
355    ///     .await?;
356    /// # Ok(())
357    /// # }
358    /// ```
359    pub fn signed_delegate_action(mut self, signed_delegate: SignedDelegateAction) -> Self {
360        // Set receiver_id to the sender of the delegate action (the original user)
361        self.receiver_id = signed_delegate.sender_id().clone();
362        self.actions.push(Action::delegate(signed_delegate));
363        self
364    }
365
366    // ========================================================================
367    // Meta-transactions (Delegate Actions)
368    // ========================================================================
369
370    /// Build and sign a delegate action for meta-transactions (NEP-366).
371    ///
372    /// This allows the user to sign a set of actions off-chain, which can then
373    /// be submitted by a relayer who pays the gas fees. The user's signature
374    /// authorizes the actions, but they don't need to hold NEAR for gas.
375    ///
376    /// # Example
377    ///
378    /// ```rust,no_run
379    /// # use near_kit::*;
380    /// # async fn example(near: Near) -> Result<(), near_kit::Error> {
381    /// // User builds and signs a delegate action
382    /// let result = near
383    ///     .transaction("contract.testnet")
384    ///     .call("add_message")
385    ///         .args(serde_json::json!({ "text": "Hello!" }))
386    ///         .gas(Gas::from_tgas(30))
387    ///     .delegate(Default::default())
388    ///     .await?;
389    ///
390    /// // Send payload to relayer via HTTP
391    /// println!("Payload to send: {}", result.payload);
392    /// # Ok(())
393    /// # }
394    /// ```
395    pub async fn delegate(self, options: DelegateOptions) -> Result<DelegateResult, Error> {
396        if self.actions.is_empty() {
397            return Err(Error::InvalidTransaction(
398                "Delegate action requires at least one action".to_string(),
399            ));
400        }
401
402        // Verify no nested delegates
403        for action in &self.actions {
404            if matches!(action, Action::Delegate(_)) {
405                return Err(Error::InvalidTransaction(
406                    "Delegate actions cannot contain nested signed delegate actions".to_string(),
407                ));
408            }
409        }
410
411        // Get the signer
412        let signer = self
413            .signer_override
414            .as_ref()
415            .or(self.signer.as_ref())
416            .ok_or(Error::NoSigner)?;
417
418        let sender_id = signer.account_id().clone();
419
420        // Get a signing key atomically
421        let key = signer.key();
422        let public_key = key.public_key().clone();
423
424        // Get nonce
425        let nonce = if let Some(n) = options.nonce {
426            n
427        } else {
428            let access_key = self
429                .rpc
430                .view_access_key(
431                    &sender_id,
432                    &public_key,
433                    BlockReference::Finality(Finality::Optimistic),
434                )
435                .await?;
436            access_key.nonce + 1
437        };
438
439        // Get max block height
440        let max_block_height = if let Some(h) = options.max_block_height {
441            h
442        } else {
443            let status = self.rpc.status().await?;
444            let offset = options.block_height_offset.unwrap_or(200);
445            status.sync_info.latest_block_height + offset
446        };
447
448        // Convert actions to NonDelegateAction
449        let delegate_actions: Vec<NonDelegateAction> = self
450            .actions
451            .into_iter()
452            .filter_map(NonDelegateAction::from_action)
453            .collect();
454
455        // Create delegate action
456        let delegate_action = DelegateAction {
457            sender_id,
458            receiver_id: self.receiver_id,
459            actions: delegate_actions,
460            nonce,
461            max_block_height,
462            public_key: public_key.clone(),
463        };
464
465        // Sign the delegate action
466        let hash = delegate_action.get_hash();
467        let signature = key.sign(hash.as_bytes()).await?;
468
469        // Create signed delegate action
470        let signed_delegate_action = delegate_action.sign(signature);
471        let payload = signed_delegate_action.to_base64();
472
473        Ok(DelegateResult {
474            signed_delegate_action,
475            payload,
476        })
477    }
478
479    // ========================================================================
480    // Global Contract Actions
481    // ========================================================================
482
483    /// Publish a contract to the global registry.
484    ///
485    /// Global contracts are deployed once and can be referenced by multiple accounts,
486    /// saving storage costs. Two modes are available via [`PublishMode`]:
487    ///
488    /// - [`PublishMode::Updatable`]: the contract is identified by the publisher's
489    ///   account and can be updated by publishing new code from the same account.
490    /// - [`PublishMode::Immutable`]: the contract is identified by its code hash and
491    ///   cannot be updated once published.
492    ///
493    /// # Example
494    ///
495    /// ```rust,no_run
496    /// # use near_kit::*;
497    /// # async fn example(near: Near) -> Result<(), Box<dyn std::error::Error>> {
498    /// let wasm_code = std::fs::read("contract.wasm")?;
499    ///
500    /// // Publish updatable contract (identified by your account)
501    /// near.transaction("alice.testnet")
502    ///     .publish(wasm_code.clone(), PublishMode::Updatable)
503    ///     .send()
504    ///     .await?;
505    ///
506    /// // Publish immutable contract (identified by its hash)
507    /// near.transaction("alice.testnet")
508    ///     .publish(wasm_code, PublishMode::Immutable)
509    ///     .send()
510    ///     .await?;
511    /// # Ok(())
512    /// # }
513    /// ```
514    pub fn publish(mut self, code: impl Into<Vec<u8>>, mode: PublishMode) -> Self {
515        self.actions.push(Action::publish(code.into(), mode));
516        self
517    }
518
519    /// Deploy a contract from the global registry.
520    ///
521    /// Accepts any [`GlobalContractRef`] (such as a [`CryptoHash`] or an account ID
522    /// string/[`AccountId`]) to reference a previously published contract.
523    ///
524    /// # Example
525    ///
526    /// ```rust,no_run
527    /// # use near_kit::*;
528    /// # async fn example(near: Near, code_hash: CryptoHash) -> Result<(), near_kit::Error> {
529    /// near.transaction("alice.testnet")
530    ///     .deploy_from(code_hash)
531    ///     .send()
532    ///     .await?;
533    /// # Ok(())
534    /// # }
535    /// ```
536    pub fn deploy_from(mut self, contract_ref: impl GlobalContractRef) -> Self {
537        let identifier = contract_ref.into_identifier();
538        self.actions.push(match identifier {
539            GlobalContractIdentifier::CodeHash(hash) => Action::deploy_from_hash(hash),
540            GlobalContractIdentifier::AccountId(id) => Action::deploy_from_account(id),
541        });
542        self
543    }
544
545    /// Create a NEP-616 deterministic state init action.
546    ///
547    /// The receiver_id is automatically set to the deterministically derived account ID:
548    /// `"0s" + hex(keccak256(borsh(state_init))[12..32])`
549    ///
550    /// # Example
551    ///
552    /// ```rust,no_run
553    /// # use near_kit::*;
554    /// # async fn example(near: Near, code_hash: CryptoHash) -> Result<(), near_kit::Error> {
555    /// let si = DeterministicAccountStateInit::by_hash(code_hash, Default::default());
556    /// let outcome = near.transaction("alice.testnet")
557    ///     .state_init(si, NearToken::from_near(1))
558    ///     .send()
559    ///     .await?;
560    /// # Ok(())
561    /// # }
562    /// ```
563    ///
564    /// # Panics
565    ///
566    /// Panics if the deposit amount string cannot be parsed.
567    pub fn state_init(
568        mut self,
569        state_init: DeterministicAccountStateInit,
570        deposit: impl IntoNearToken,
571    ) -> Self {
572        let deposit = deposit
573            .into_near_token()
574            .expect("invalid deposit amount - use NearToken::from_str() for user input");
575
576        self.receiver_id = state_init.derive_account_id();
577        self.actions.push(Action::state_init(state_init, deposit));
578        self
579    }
580
581    /// Add a pre-built action to the transaction.
582    ///
583    /// This is the most flexible way to add actions, since it accepts any
584    /// [`Action`] variant directly. It's especially useful when you want to
585    /// build function call actions independently and attach them later, or
586    /// when working with action types that don't have dedicated builder
587    /// methods.
588    ///
589    /// # Example
590    ///
591    /// ```rust,no_run
592    /// # use near_kit::*;
593    /// # async fn example(near: Near) -> Result<(), near_kit::Error> {
594    /// let action = Action::function_call(
595    ///     "transfer",
596    ///     serde_json::to_vec(&serde_json::json!({ "receiver": "bob.testnet" }))?,
597    ///     Gas::from_tgas(30),
598    ///     NearToken::ZERO,
599    /// );
600    ///
601    /// near.transaction("contract.testnet")
602    ///     .add_action(action)
603    ///     .send()
604    ///     .await?;
605    /// # Ok(())
606    /// # }
607    /// ```
608    pub fn add_action(mut self, action: impl Into<Action>) -> Self {
609        self.actions.push(action.into());
610        self
611    }
612
613    // ========================================================================
614    // Configuration methods
615    // ========================================================================
616
617    /// Override the signer for this transaction.
618    pub fn sign_with(mut self, signer: impl Signer + 'static) -> Self {
619        self.signer_override = Some(Arc::new(signer));
620        self
621    }
622
623    /// Set the execution wait level and prepare to send.
624    ///
625    /// This is a shorthand for `.send().wait_until(level)`.
626    /// The return type changes based on the wait level — see [`TransactionSend::wait_until`].
627    pub fn wait_until<W: crate::types::WaitLevel>(self, level: W) -> TransactionSend<W> {
628        self.send().wait_until(level)
629    }
630
631    /// Override the number of nonce retries for this transaction on `InvalidNonce`
632    /// errors. `0` means no retries (send once), `1` means one retry, etc.
633    pub fn max_nonce_retries(mut self, retries: u32) -> Self {
634        self.max_nonce_retries = retries;
635        self
636    }
637
638    // ========================================================================
639    // Execution
640    // ========================================================================
641
642    /// Sign the transaction without sending it.
643    ///
644    /// Returns a `SignedTransaction` that can be inspected or sent later.
645    ///
646    /// # Example
647    ///
648    /// ```rust,no_run
649    /// # use near_kit::*;
650    /// # async fn example(near: Near) -> Result<(), near_kit::Error> {
651    /// let signed = near.transaction("bob.testnet")
652    ///     .transfer(NearToken::from_near(1))
653    ///     .sign()
654    ///     .await?;
655    ///
656    /// // Inspect the transaction
657    /// println!("Hash: {}", signed.transaction.get_hash());
658    /// println!("Actions: {:?}", signed.transaction.actions);
659    ///
660    /// // Send it later
661    /// let outcome = near.send(&signed).await?;
662    /// # Ok(())
663    /// # }
664    /// ```
665    pub async fn sign(self) -> Result<SignedTransaction, Error> {
666        if self.actions.is_empty() {
667            return Err(Error::InvalidTransaction(
668                "Transaction must have at least one action".to_string(),
669            ));
670        }
671
672        let signer = self
673            .signer_override
674            .or(self.signer)
675            .ok_or(Error::NoSigner)?;
676
677        let signer_id = signer.account_id().clone();
678        let action_count = self.actions.len();
679
680        let span = tracing::info_span!(
681            "sign_transaction",
682            sender = %signer_id,
683            receiver = %self.receiver_id,
684            action_count,
685        );
686
687        async move {
688            // Get a signing key atomically. For RotatingSigner, this claims the next
689            // key in rotation. The key contains both the public key and signing capability.
690            let key = signer.key();
691            let public_key = key.public_key().clone();
692
693            // Single view_access_key call provides both nonce and block_hash.
694            // Uses Finality::Final for block hash stability.
695            let access_key = self
696                .rpc
697                .view_access_key(
698                    &signer_id,
699                    &public_key,
700                    BlockReference::Finality(Finality::Final),
701                )
702                .await?;
703            let block_hash = access_key.block_hash;
704
705            let network = self.rpc.url().to_string();
706            let nonce = nonce_manager().next(
707                network,
708                signer_id.clone(),
709                public_key.clone(),
710                access_key.nonce,
711            );
712
713            // Build transaction
714            let tx = Transaction::new(
715                signer_id,
716                public_key,
717                nonce,
718                self.receiver_id,
719                block_hash,
720                self.actions,
721            );
722
723            // Sign with the key
724            let tx_hash = tx.get_hash();
725            let signature = key.sign(tx_hash.as_bytes()).await?;
726
727            tracing::debug!(tx_hash = %tx_hash, nonce, "Transaction signed");
728
729            Ok(SignedTransaction {
730                transaction: tx,
731                signature,
732            })
733        }
734        .instrument(span)
735        .await
736    }
737
738    /// Sign the transaction offline without network access.
739    ///
740    /// This is useful for air-gapped signing workflows where you need to
741    /// provide the block hash and nonce manually (obtained from a separate
742    /// online machine).
743    ///
744    /// # Arguments
745    ///
746    /// * `block_hash` - A recent block hash (transaction expires ~24h after this block)
747    /// * `nonce` - The next nonce for the signing key (current nonce + 1)
748    ///
749    /// # Example
750    ///
751    /// ```rust,ignore
752    /// # use near_kit::*;
753    /// // On online machine: get block hash and nonce
754    /// // let block = near.rpc().block(BlockReference::latest()).await?;
755    /// // let access_key = near.rpc().view_access_key(...).await?;
756    ///
757    /// // On offline machine: sign with pre-fetched values
758    /// let block_hash: CryptoHash = "11111111111111111111111111111111".parse().unwrap();
759    /// let nonce = 12345u64;
760    ///
761    /// let signed = near.transaction("bob.testnet")
762    ///     .transfer(NearToken::from_near(1))
763    ///     .sign_offline(block_hash, nonce)
764    ///     .await?;
765    ///
766    /// // Transport signed_tx.to_base64() back to online machine
767    /// ```
768    pub async fn sign_offline(
769        self,
770        block_hash: CryptoHash,
771        nonce: u64,
772    ) -> Result<SignedTransaction, Error> {
773        if self.actions.is_empty() {
774            return Err(Error::InvalidTransaction(
775                "Transaction must have at least one action".to_string(),
776            ));
777        }
778
779        let signer = self
780            .signer_override
781            .or(self.signer)
782            .ok_or(Error::NoSigner)?;
783
784        let signer_id = signer.account_id().clone();
785
786        // Get a signing key atomically
787        let key = signer.key();
788        let public_key = key.public_key().clone();
789
790        // Build transaction with provided block_hash and nonce
791        let tx = Transaction::new(
792            signer_id,
793            public_key,
794            nonce,
795            self.receiver_id,
796            block_hash,
797            self.actions,
798        );
799
800        // Sign
801        let signature = key.sign(tx.get_hash().as_bytes()).await?;
802
803        Ok(SignedTransaction {
804            transaction: tx,
805            signature,
806        })
807    }
808
809    /// Send the transaction.
810    ///
811    /// Returns a [`TransactionSend`] that defaults to [`crate::types::ExecutedOptimistic`] wait level.
812    /// Chain `.wait_until(...)` to change the wait level before awaiting.
813    pub fn send(self) -> TransactionSend {
814        TransactionSend {
815            builder: self,
816            _marker: std::marker::PhantomData,
817        }
818    }
819}
820
821// ============================================================================
822// FunctionCall
823// ============================================================================
824
825/// A standalone function call configuration, decoupled from any transaction.
826///
827/// Use this when you need to pre-build calls and compose them into a transaction
828/// later. This is especially useful for dynamic transaction composition (e.g. in
829/// a loop) or for batching typed contract calls into a single transaction.
830///
831/// Note: `FunctionCall` does not capture a receiver/contract account. The call
832/// will execute against whichever `receiver_id` is set on the transaction it's
833/// added to.
834///
835/// # Examples
836///
837/// ```rust,no_run
838/// # use near_kit::*;
839/// # async fn example(near: Near) -> Result<(), near_kit::Error> {
840/// // Pre-build calls independently
841/// let init = FunctionCall::new("init")
842///     .args(serde_json::json!({"owner": "alice.testnet"}))
843///     .gas(Gas::from_tgas(50));
844///
845/// let notify = FunctionCall::new("notify")
846///     .args(serde_json::json!({"msg": "done"}));
847///
848/// // Compose into a single atomic transaction
849/// near.transaction("contract.testnet")
850///     .add_action(init)
851///     .add_action(notify)
852///     .send()
853///     .await?;
854/// # Ok(())
855/// # }
856/// ```
857///
858/// ```rust,no_run
859/// # use near_kit::*;
860/// # async fn example(near: Near) -> Result<(), near_kit::Error> {
861/// // Dynamic composition in a loop
862/// let calls = vec![
863///     FunctionCall::new("method_a").args(serde_json::json!({"x": 1})),
864///     FunctionCall::new("method_b").args(serde_json::json!({"y": 2})),
865/// ];
866///
867/// let mut tx = near.transaction("contract.testnet");
868/// for call in calls {
869///     tx = tx.add_action(call);
870/// }
871/// tx.send().await?;
872/// # Ok(())
873/// # }
874/// ```
875pub struct FunctionCall {
876    method: String,
877    args: Vec<u8>,
878    gas: Gas,
879    deposit: NearToken,
880}
881
882impl fmt::Debug for FunctionCall {
883    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
884        f.debug_struct("FunctionCall")
885            .field("method", &self.method)
886            .field("args_len", &self.args.len())
887            .field("gas", &self.gas)
888            .field("deposit", &self.deposit)
889            .finish()
890    }
891}
892
893impl FunctionCall {
894    /// Create a new function call for the given method name.
895    pub fn new(method: impl Into<String>) -> Self {
896        Self {
897            method: method.into(),
898            args: Vec::new(),
899            gas: Gas::from_tgas(30),
900            deposit: NearToken::ZERO,
901        }
902    }
903
904    /// Set JSON arguments.
905    pub fn args(mut self, args: impl serde::Serialize) -> Self {
906        self.args = serde_json::to_vec(&args).unwrap_or_default();
907        self
908    }
909
910    /// Set raw byte arguments.
911    pub fn args_raw(mut self, args: Vec<u8>) -> Self {
912        self.args = args;
913        self
914    }
915
916    /// Set Borsh-encoded arguments.
917    pub fn args_borsh(mut self, args: impl borsh::BorshSerialize) -> Self {
918        self.args = borsh::to_vec(&args).unwrap_or_default();
919        self
920    }
921
922    /// Set gas limit.
923    ///
924    /// Defaults to 30 TGas if not set.
925    ///
926    /// # Panics
927    ///
928    /// Panics if the gas string cannot be parsed. Use [`Gas`]'s `FromStr` impl
929    /// for fallible parsing of user input.
930    pub fn gas(mut self, gas: impl IntoGas) -> Self {
931        self.gas = gas
932            .into_gas()
933            .expect("invalid gas format - use Gas::from_str() for user input");
934        self
935    }
936
937    /// Set attached deposit.
938    ///
939    /// Defaults to zero if not set.
940    ///
941    /// # Panics
942    ///
943    /// Panics if the amount string cannot be parsed. Use [`NearToken`]'s `FromStr`
944    /// impl for fallible parsing of user input.
945    pub fn deposit(mut self, amount: impl IntoNearToken) -> Self {
946        self.deposit = amount
947            .into_near_token()
948            .expect("invalid deposit amount - use NearToken::from_str() for user input");
949        self
950    }
951}
952
953impl From<FunctionCall> for Action {
954    fn from(call: FunctionCall) -> Self {
955        Action::function_call(call.method, call.args, call.gas, call.deposit)
956    }
957}
958
959// ============================================================================
960// CallBuilder
961// ============================================================================
962
963/// Builder for configuring a function call within a transaction.
964///
965/// Created via [`TransactionBuilder::call`]. Allows setting args, gas, and deposit
966/// before continuing to chain more actions or sending.
967pub struct CallBuilder {
968    builder: TransactionBuilder,
969    call: FunctionCall,
970}
971
972impl fmt::Debug for CallBuilder {
973    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
974        f.debug_struct("CallBuilder")
975            .field("call", &self.call)
976            .field("builder", &self.builder)
977            .finish()
978    }
979}
980
981impl CallBuilder {
982    fn new(builder: TransactionBuilder, method: String) -> Self {
983        Self {
984            builder,
985            call: FunctionCall::new(method),
986        }
987    }
988
989    /// Set JSON arguments.
990    pub fn args<A: serde::Serialize>(mut self, args: A) -> Self {
991        self.call = self.call.args(args);
992        self
993    }
994
995    /// Set raw byte arguments.
996    pub fn args_raw(mut self, args: Vec<u8>) -> Self {
997        self.call = self.call.args_raw(args);
998        self
999    }
1000
1001    /// Set Borsh-encoded arguments.
1002    pub fn args_borsh<A: borsh::BorshSerialize>(mut self, args: A) -> Self {
1003        self.call = self.call.args_borsh(args);
1004        self
1005    }
1006
1007    /// Set gas limit.
1008    ///
1009    /// # Example
1010    ///
1011    /// ```rust,no_run
1012    /// # use near_kit::*;
1013    /// # async fn example(near: Near) -> Result<(), near_kit::Error> {
1014    /// near.transaction("contract.testnet")
1015    ///     .call("method")
1016    ///         .gas(Gas::from_tgas(50))
1017    ///     .send()
1018    ///     .await?;
1019    /// # Ok(())
1020    /// # }
1021    /// ```
1022    ///
1023    /// # Panics
1024    ///
1025    /// Panics if the gas string cannot be parsed. Use [`Gas`]'s `FromStr` impl
1026    /// for fallible parsing of user input.
1027    pub fn gas(mut self, gas: impl IntoGas) -> Self {
1028        self.call = self.call.gas(gas);
1029        self
1030    }
1031
1032    /// Set attached deposit.
1033    ///
1034    /// # Example
1035    ///
1036    /// ```rust,no_run
1037    /// # use near_kit::*;
1038    /// # async fn example(near: Near) -> Result<(), near_kit::Error> {
1039    /// near.transaction("contract.testnet")
1040    ///     .call("method")
1041    ///         .deposit(NearToken::from_near(1))
1042    ///     .send()
1043    ///     .await?;
1044    /// # Ok(())
1045    /// # }
1046    /// ```
1047    ///
1048    /// # Panics
1049    ///
1050    /// Panics if the amount string cannot be parsed. Use [`NearToken`]'s `FromStr`
1051    /// impl for fallible parsing of user input.
1052    pub fn deposit(mut self, amount: impl IntoNearToken) -> Self {
1053        self.call = self.call.deposit(amount);
1054        self
1055    }
1056
1057    /// Convert this call into a standalone [`Action`], discarding the
1058    /// underlying transaction builder.
1059    ///
1060    /// This is useful for extracting a typed contract call so it can be
1061    /// composed into a different transaction.
1062    ///
1063    /// # Example
1064    ///
1065    /// ```rust,no_run
1066    /// # use near_kit::*;
1067    /// # async fn example(near: Near) -> Result<(), near_kit::Error> {
1068    /// // Extract actions from the fluent builder
1069    /// let action = near.transaction("contract.testnet")
1070    ///     .call("method")
1071    ///     .args(serde_json::json!({"key": "value"}))
1072    ///     .gas(Gas::from_tgas(50))
1073    ///     .into_action();
1074    ///
1075    /// // Compose into a different transaction
1076    /// near.transaction("contract.testnet")
1077    ///     .add_action(action)
1078    ///     .send()
1079    ///     .await?;
1080    /// # Ok(())
1081    /// # }
1082    /// ```
1083    ///
1084    /// # Panics
1085    ///
1086    /// Panics if the underlying transaction builder already has accumulated
1087    /// actions, since those would be silently dropped. Use [`finish`](Self::finish)
1088    /// instead when chaining multiple actions on the same transaction.
1089    pub fn into_action(self) -> Action {
1090        assert!(
1091            self.builder.actions.is_empty(),
1092            "into_action() discards {} previously accumulated action(s) — \
1093             use .finish() to keep them in the transaction",
1094            self.builder.actions.len(),
1095        );
1096        self.call.into()
1097    }
1098
1099    /// Finish this call and return to the transaction builder.
1100    ///
1101    /// This is useful when you need to conditionally add actions to a
1102    /// transaction, since it gives back the [`TransactionBuilder`] so you can
1103    /// branch on runtime state before starting the next action.
1104    pub fn finish(self) -> TransactionBuilder {
1105        self.builder.add_action(self.call)
1106    }
1107
1108    // ========================================================================
1109    // Chaining methods (delegate to TransactionBuilder after finishing)
1110    // ========================================================================
1111
1112    /// Add a pre-built action to the transaction.
1113    ///
1114    /// Finishes this function call, then adds the given action.
1115    /// See [`TransactionBuilder::add_action`] for details.
1116    pub fn add_action(self, action: impl Into<Action>) -> TransactionBuilder {
1117        self.finish().add_action(action)
1118    }
1119
1120    /// Add another function call.
1121    pub fn call(self, method: &str) -> CallBuilder {
1122        self.finish().call(method)
1123    }
1124
1125    /// Add a create account action.
1126    pub fn create_account(self) -> TransactionBuilder {
1127        self.finish().create_account()
1128    }
1129
1130    /// Add a transfer action.
1131    pub fn transfer(self, amount: impl IntoNearToken) -> TransactionBuilder {
1132        self.finish().transfer(amount)
1133    }
1134
1135    /// Add a deploy contract action.
1136    pub fn deploy(self, code: impl Into<Vec<u8>>) -> TransactionBuilder {
1137        self.finish().deploy(code)
1138    }
1139
1140    /// Add a full access key.
1141    pub fn add_full_access_key(self, public_key: PublicKey) -> TransactionBuilder {
1142        self.finish().add_full_access_key(public_key)
1143    }
1144
1145    /// Add a function call access key.
1146    pub fn add_function_call_key(
1147        self,
1148        public_key: PublicKey,
1149        receiver_id: impl TryIntoAccountId,
1150        method_names: Vec<String>,
1151        allowance: Option<NearToken>,
1152    ) -> TransactionBuilder {
1153        self.finish()
1154            .add_function_call_key(public_key, receiver_id, method_names, allowance)
1155    }
1156
1157    /// Delete an access key.
1158    pub fn delete_key(self, public_key: PublicKey) -> TransactionBuilder {
1159        self.finish().delete_key(public_key)
1160    }
1161
1162    /// Delete the account.
1163    pub fn delete_account(self, beneficiary_id: impl TryIntoAccountId) -> TransactionBuilder {
1164        self.finish().delete_account(beneficiary_id)
1165    }
1166
1167    /// Add a stake action.
1168    pub fn stake(self, amount: impl IntoNearToken, public_key: PublicKey) -> TransactionBuilder {
1169        self.finish().stake(amount, public_key)
1170    }
1171
1172    /// Publish a contract to the global registry.
1173    pub fn publish(self, code: impl Into<Vec<u8>>, mode: PublishMode) -> TransactionBuilder {
1174        self.finish().publish(code, mode)
1175    }
1176
1177    /// Deploy a contract from the global registry.
1178    pub fn deploy_from(self, contract_ref: impl GlobalContractRef) -> TransactionBuilder {
1179        self.finish().deploy_from(contract_ref)
1180    }
1181
1182    /// Create a NEP-616 deterministic state init action.
1183    pub fn state_init(
1184        self,
1185        state_init: DeterministicAccountStateInit,
1186        deposit: impl IntoNearToken,
1187    ) -> TransactionBuilder {
1188        self.finish().state_init(state_init, deposit)
1189    }
1190
1191    /// Override the signer.
1192    pub fn sign_with(self, signer: impl Signer + 'static) -> TransactionBuilder {
1193        self.finish().sign_with(signer)
1194    }
1195
1196    /// Set the execution wait level.
1197    pub fn wait_until<W: WaitLevel>(self, level: W) -> TransactionSend<W> {
1198        self.finish().wait_until(level)
1199    }
1200
1201    /// Override the number of nonce retries for this transaction on `InvalidNonce`
1202    /// errors. `0` means no retries (send once), `1` means one retry, etc.
1203    pub fn max_nonce_retries(self, retries: u32) -> TransactionBuilder {
1204        self.finish().max_nonce_retries(retries)
1205    }
1206
1207    /// Build and sign a delegate action for meta-transactions (NEP-366).
1208    ///
1209    /// This finishes the current function call and then creates a delegate action.
1210    pub async fn delegate(self, options: DelegateOptions) -> Result<DelegateResult, crate::Error> {
1211        self.finish().delegate(options).await
1212    }
1213
1214    /// Sign the transaction offline without network access.
1215    ///
1216    /// See [`TransactionBuilder::sign_offline`] for details.
1217    pub async fn sign_offline(
1218        self,
1219        block_hash: CryptoHash,
1220        nonce: u64,
1221    ) -> Result<SignedTransaction, Error> {
1222        self.finish().sign_offline(block_hash, nonce).await
1223    }
1224
1225    /// Sign the transaction without sending it.
1226    ///
1227    /// See [`TransactionBuilder::sign`] for details.
1228    pub async fn sign(self) -> Result<SignedTransaction, Error> {
1229        self.finish().sign().await
1230    }
1231
1232    /// Send the transaction.
1233    pub fn send(self) -> TransactionSend {
1234        self.finish().send()
1235    }
1236}
1237
1238impl IntoFuture for CallBuilder {
1239    type Output = Result<FinalExecutionOutcome, Error>;
1240    type IntoFuture = Pin<Box<dyn Future<Output = Self::Output> + Send>>;
1241
1242    fn into_future(self) -> Self::IntoFuture {
1243        self.send().into_future()
1244    }
1245}
1246
1247// ============================================================================
1248// TransactionSend
1249// ============================================================================
1250
1251/// Future for sending a transaction.
1252///
1253/// The type parameter `W` determines the wait level and the return type:
1254/// - Executed levels ([`crate::types::ExecutedOptimistic`], [`crate::types::Executed`],
1255///   [`crate::types::Final`]) → [`FinalExecutionOutcome`]
1256/// - Non-executed levels ([`crate::types::Submitted`], [`crate::types::Included`],
1257///   [`crate::types::IncludedFinal`]) → [`crate::types::SendTxResponse`]
1258pub struct TransactionSend<W: WaitLevel = crate::types::ExecutedOptimistic> {
1259    builder: TransactionBuilder,
1260    _marker: std::marker::PhantomData<W>,
1261}
1262
1263impl<W: WaitLevel> TransactionSend<W> {
1264    /// Change the execution wait level.
1265    ///
1266    /// The return type changes based on the wait level:
1267    ///
1268    /// ```rust,no_run
1269    /// # use near_kit::*;
1270    /// # async fn example(near: &Near) -> Result<(), Error> {
1271    /// // Executed levels return FinalExecutionOutcome
1272    /// let outcome = near.transfer("bob.testnet", NearToken::from_near(1))
1273    ///     .send()
1274    ///     .wait_until(Final)
1275    ///     .await?;
1276    ///
1277    /// // Non-executed levels return SendTxResponse
1278    /// let response = near.transfer("bob.testnet", NearToken::from_near(1))
1279    ///     .send()
1280    ///     .wait_until(Included)
1281    ///     .await?;
1282    /// # Ok(())
1283    /// # }
1284    /// ```
1285    pub fn wait_until<W2: WaitLevel>(self, _level: W2) -> TransactionSend<W2> {
1286        TransactionSend {
1287            builder: self.builder,
1288            _marker: std::marker::PhantomData,
1289        }
1290    }
1291
1292    /// Override the number of nonce retries for this transaction on `InvalidNonce`
1293    /// errors. `0` means no retries (send once), `1` means one retry, etc.
1294    pub fn max_nonce_retries(mut self, retries: u32) -> Self {
1295        self.builder.max_nonce_retries = retries;
1296        self
1297    }
1298}
1299
1300impl<W: WaitLevel> IntoFuture for TransactionSend<W> {
1301    type Output = Result<W::Response, Error>;
1302    type IntoFuture = Pin<Box<dyn Future<Output = Self::Output> + Send>>;
1303
1304    fn into_future(self) -> Self::IntoFuture {
1305        Box::pin(async move {
1306            let builder = self.builder;
1307
1308            if builder.actions.is_empty() {
1309                return Err(Error::InvalidTransaction(
1310                    "Transaction must have at least one action".to_string(),
1311                ));
1312            }
1313
1314            let signer = builder
1315                .signer_override
1316                .as_ref()
1317                .or(builder.signer.as_ref())
1318                .ok_or(Error::NoSigner)?;
1319
1320            let signer_id = signer.account_id().clone();
1321
1322            let span = tracing::info_span!(
1323                "send_transaction",
1324                sender = %signer_id,
1325                receiver = %builder.receiver_id,
1326                action_count = builder.actions.len(),
1327            );
1328
1329            async move {
1330                // Retry loop for transient InvalidTxErrors (nonce conflicts, expired block hash)
1331                let max_nonce_retries = builder.max_nonce_retries;
1332                let wait_until = W::status();
1333                let network = builder.rpc.url().to_string();
1334                let mut last_error: Option<Error> = None;
1335                let mut last_ak_nonce: Option<u64> = None;
1336
1337                for attempt in 0..=max_nonce_retries {
1338                    // Get a signing key atomically for this attempt
1339                    let key = signer.key();
1340                    let public_key = key.public_key().clone();
1341
1342                    // Single view_access_key call provides both nonce and block_hash.
1343                    // Uses Finality::Final for block hash stability.
1344                    let access_key = builder
1345                        .rpc
1346                        .view_access_key(
1347                            &signer_id,
1348                            &public_key,
1349                            BlockReference::Finality(Finality::Final),
1350                        )
1351                        .await?;
1352                    let block_hash = access_key.block_hash;
1353
1354                    // Resolve nonce: prefer ak_nonce from a prior InvalidNonce
1355                    // error (more recent than the view_access_key result), then
1356                    // fall back to the chain nonce. The nonce manager takes
1357                    // max(cached, provided) so stale values are harmless.
1358                    let nonce = nonce_manager().next(
1359                        network.clone(),
1360                        signer_id.clone(),
1361                        public_key.clone(),
1362                        last_ak_nonce.take().unwrap_or(access_key.nonce),
1363                    );
1364
1365                    // Build transaction
1366                    let tx = Transaction::new(
1367                        signer_id.clone(),
1368                        public_key.clone(),
1369                        nonce,
1370                        builder.receiver_id.clone(),
1371                        block_hash,
1372                        builder.actions.clone(),
1373                    );
1374
1375                    // Sign with the key
1376                    let signature = match key.sign(tx.get_hash().as_bytes()).await {
1377                        Ok(sig) => sig,
1378                        Err(e) => return Err(Error::Signing(e)),
1379                    };
1380                    let signed_tx = crate::types::SignedTransaction {
1381                        transaction: tx,
1382                        signature,
1383                    };
1384
1385                    // Send
1386                    match builder.rpc.send_tx(&signed_tx, wait_until).await {
1387                        Ok(response) => {
1388                            // W::convert handles the response appropriately:
1389                            // - Executed levels: extract outcome, check for InvalidTxError
1390                            // - Non-executed levels: return SendTxResponse directly
1391                            return W::convert(response);
1392                        }
1393                        Err(RpcError::InvalidTx(
1394                            crate::types::InvalidTxError::InvalidNonce { tx_nonce, ak_nonce },
1395                        )) if attempt < max_nonce_retries => {
1396                            tracing::warn!(
1397                                tx_nonce = tx_nonce,
1398                                ak_nonce = ak_nonce,
1399                                attempt = attempt + 1,
1400                                "Invalid nonce, retrying"
1401                            );
1402                            // Store ak_nonce for next iteration to avoid refetching
1403                            last_ak_nonce = Some(ak_nonce);
1404                            last_error = Some(Error::InvalidTx(Box::new(
1405                                crate::types::InvalidTxError::InvalidNonce { tx_nonce, ak_nonce },
1406                            )));
1407                            continue;
1408                        }
1409                        Err(RpcError::InvalidTx(crate::types::InvalidTxError::Expired))
1410                            if attempt + 1 < max_nonce_retries =>
1411                        {
1412                            tracing::warn!(
1413                                attempt = attempt + 1,
1414                                "Transaction expired (stale block hash), retrying with fresh block hash"
1415                            );
1416                            // Expired tx was rejected before nonce consumption.
1417                            // No cache invalidation needed: the next iteration calls
1418                            // view_access_key which provides a fresh nonce, and
1419                            // next() uses max(cached, chain) so stale cache is harmless.
1420                            last_error = Some(Error::InvalidTx(Box::new(
1421                                crate::types::InvalidTxError::Expired,
1422                            )));
1423                            continue;
1424                        }
1425                        Err(e) => {
1426                            tracing::error!(error = %e, "Transaction send failed");
1427                            return Err(e.into());
1428                        }
1429                    }
1430                }
1431
1432                Err(last_error.unwrap_or_else(|| {
1433                    Error::InvalidTransaction("Unknown error during transaction send".to_string())
1434                }))
1435            }
1436            .instrument(span)
1437            .await
1438        })
1439    }
1440}
1441
1442impl IntoFuture for TransactionBuilder {
1443    type Output = Result<FinalExecutionOutcome, Error>;
1444    type IntoFuture = Pin<Box<dyn Future<Output = Self::Output> + Send>>;
1445
1446    fn into_future(self) -> Self::IntoFuture {
1447        self.send().into_future()
1448    }
1449}
1450
1451#[cfg(test)]
1452mod tests {
1453    use super::*;
1454
1455    /// Create a TransactionBuilder for unit tests (no real network needed).
1456    fn test_builder() -> TransactionBuilder {
1457        let rpc = Arc::new(RpcClient::new("https://rpc.testnet.near.org"));
1458        let receiver: AccountId = "contract.testnet".parse().unwrap();
1459        TransactionBuilder::new(rpc, None, receiver, 0)
1460    }
1461
1462    #[test]
1463    fn add_action_appends_to_transaction() {
1464        let action = Action::function_call(
1465            "do_something",
1466            serde_json::to_vec(&serde_json::json!({ "key": "value" })).unwrap(),
1467            Gas::from_tgas(30),
1468            NearToken::ZERO,
1469        );
1470
1471        let builder = test_builder().add_action(action);
1472        assert_eq!(builder.actions.len(), 1);
1473    }
1474
1475    #[test]
1476    fn add_action_chains_with_other_actions() {
1477        let call_action =
1478            Action::function_call("init", Vec::new(), Gas::from_tgas(10), NearToken::ZERO);
1479
1480        let builder = test_builder()
1481            .create_account()
1482            .transfer(NearToken::from_near(5))
1483            .add_action(call_action);
1484
1485        assert_eq!(builder.actions.len(), 3);
1486    }
1487
1488    #[test]
1489    fn add_action_works_after_call_builder() {
1490        let extra_action = Action::transfer(NearToken::from_near(1));
1491
1492        let builder = test_builder()
1493            .call("setup")
1494            .args(serde_json::json!({ "admin": "alice.testnet" }))
1495            .gas(Gas::from_tgas(50))
1496            .add_action(extra_action);
1497
1498        // Should have two actions: the function call from CallBuilder + the transfer
1499        assert_eq!(builder.actions.len(), 2);
1500    }
1501
1502    // FunctionCall tests
1503
1504    #[test]
1505    fn function_call_into_action() {
1506        let call = FunctionCall::new("init")
1507            .args(serde_json::json!({"owner": "alice.testnet"}))
1508            .gas(Gas::from_tgas(50))
1509            .deposit(NearToken::from_near(1));
1510
1511        let action: Action = call.into();
1512        match &action {
1513            Action::FunctionCall(fc) => {
1514                assert_eq!(fc.method_name, "init");
1515                assert_eq!(
1516                    fc.args,
1517                    serde_json::to_vec(&serde_json::json!({"owner": "alice.testnet"})).unwrap()
1518                );
1519                assert_eq!(fc.gas, Gas::from_tgas(50));
1520                assert_eq!(fc.deposit, NearToken::from_near(1));
1521            }
1522            other => panic!("expected FunctionCall, got {:?}", other),
1523        }
1524    }
1525
1526    #[test]
1527    fn function_call_defaults() {
1528        let call = FunctionCall::new("method");
1529        let action: Action = call.into();
1530        match &action {
1531            Action::FunctionCall(fc) => {
1532                assert_eq!(fc.method_name, "method");
1533                assert!(fc.args.is_empty());
1534                assert_eq!(fc.gas, Gas::from_tgas(30));
1535                assert_eq!(fc.deposit, NearToken::ZERO);
1536            }
1537            other => panic!("expected FunctionCall, got {:?}", other),
1538        }
1539    }
1540
1541    #[test]
1542    fn function_call_compose_into_transaction() {
1543        let init = FunctionCall::new("init")
1544            .args(serde_json::json!({"owner": "alice.testnet"}))
1545            .gas(Gas::from_tgas(50));
1546
1547        let notify = FunctionCall::new("notify").args(serde_json::json!({"msg": "done"}));
1548
1549        let builder = test_builder()
1550            .deploy(vec![0u8])
1551            .add_action(init)
1552            .add_action(notify);
1553
1554        assert_eq!(builder.actions.len(), 3);
1555    }
1556
1557    #[test]
1558    fn function_call_dynamic_loop_composition() {
1559        let methods = vec!["step1", "step2", "step3"];
1560
1561        let mut tx = test_builder();
1562        for method in methods {
1563            tx = tx.add_action(FunctionCall::new(method));
1564        }
1565
1566        assert_eq!(tx.actions.len(), 3);
1567    }
1568
1569    #[test]
1570    fn call_builder_into_action() {
1571        let action = test_builder()
1572            .call("setup")
1573            .args(serde_json::json!({"admin": "alice.testnet"}))
1574            .gas(Gas::from_tgas(50))
1575            .deposit(NearToken::from_near(1))
1576            .into_action();
1577
1578        match &action {
1579            Action::FunctionCall(fc) => {
1580                assert_eq!(fc.method_name, "setup");
1581                assert_eq!(fc.gas, Gas::from_tgas(50));
1582                assert_eq!(fc.deposit, NearToken::from_near(1));
1583            }
1584            other => panic!("expected FunctionCall, got {:?}", other),
1585        }
1586    }
1587
1588    #[test]
1589    fn call_builder_into_action_compose() {
1590        let action1 = test_builder()
1591            .call("method_a")
1592            .gas(Gas::from_tgas(50))
1593            .into_action();
1594
1595        let action2 = test_builder()
1596            .call("method_b")
1597            .deposit(NearToken::from_near(1))
1598            .into_action();
1599
1600        let builder = test_builder().add_action(action1).add_action(action2);
1601
1602        assert_eq!(builder.actions.len(), 2);
1603    }
1604}