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