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}