use std::fmt;
use std::future::{Future, IntoFuture};
use std::pin::Pin;
use std::sync::{Arc, OnceLock};
use tracing::Instrument;
use crate::error::{Error, RpcError};
use crate::types::{
AccountId, Action, BlockReference, CryptoHash, DelegateAction, DeterministicAccountStateInit,
FinalExecutionOutcome, Finality, Gas, GlobalContractIdentifier, GlobalContractRef, IntoGas,
IntoNearToken, NearToken, NonDelegateAction, PublicKey, PublishMode, SignedDelegateAction,
SignedTransaction, Transaction, TryIntoAccountId, TxExecutionStatus,
};
use super::nonce_manager::NonceManager;
use super::rpc::RpcClient;
use super::signer::Signer;
fn nonce_manager() -> &'static NonceManager {
static NONCE_MANAGER: OnceLock<NonceManager> = OnceLock::new();
NONCE_MANAGER.get_or_init(NonceManager::new)
}
#[derive(Clone, Debug, Default)]
pub struct DelegateOptions {
pub max_block_height: Option<u64>,
pub block_height_offset: Option<u64>,
pub nonce: Option<u64>,
}
impl DelegateOptions {
pub fn with_offset(offset: u64) -> Self {
Self {
block_height_offset: Some(offset),
..Default::default()
}
}
pub fn with_max_height(height: u64) -> Self {
Self {
max_block_height: Some(height),
..Default::default()
}
}
}
#[derive(Clone, Debug)]
pub struct DelegateResult {
pub signed_delegate_action: SignedDelegateAction,
pub payload: String,
}
impl DelegateResult {
pub fn to_bytes(&self) -> Vec<u8> {
self.signed_delegate_action.to_bytes()
}
pub fn sender_id(&self) -> &AccountId {
self.signed_delegate_action.sender_id()
}
pub fn receiver_id(&self) -> &AccountId {
self.signed_delegate_action.receiver_id()
}
}
pub struct TransactionBuilder {
rpc: Arc<RpcClient>,
signer: Option<Arc<dyn Signer>>,
receiver_id: AccountId,
actions: Vec<Action>,
signer_override: Option<Arc<dyn Signer>>,
wait_until: TxExecutionStatus,
max_nonce_retries: u32,
}
impl fmt::Debug for TransactionBuilder {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("TransactionBuilder")
.field(
"signer_id",
&self
.signer_override
.as_ref()
.or(self.signer.as_ref())
.map(|s| s.account_id()),
)
.field("receiver_id", &self.receiver_id)
.field("action_count", &self.actions.len())
.field("wait_until", &self.wait_until)
.field("max_nonce_retries", &self.max_nonce_retries)
.finish()
}
}
impl TransactionBuilder {
pub(crate) fn new(
rpc: Arc<RpcClient>,
signer: Option<Arc<dyn Signer>>,
receiver_id: AccountId,
max_nonce_retries: u32,
) -> Self {
Self {
rpc,
signer,
receiver_id,
actions: Vec::new(),
signer_override: None,
wait_until: TxExecutionStatus::ExecutedOptimistic,
max_nonce_retries,
}
}
pub fn create_account(mut self) -> Self {
self.actions.push(Action::create_account());
self
}
pub fn transfer(mut self, amount: impl IntoNearToken) -> Self {
let amount = amount
.into_near_token()
.expect("invalid transfer amount - use NearToken::from_str() for user input");
self.actions.push(Action::transfer(amount));
self
}
pub fn deploy(mut self, code: impl Into<Vec<u8>>) -> Self {
self.actions.push(Action::deploy_contract(code.into()));
self
}
pub fn call(self, method: &str) -> CallBuilder {
CallBuilder::new(self, method.to_string())
}
pub fn add_full_access_key(mut self, public_key: PublicKey) -> Self {
self.actions.push(Action::add_full_access_key(public_key));
self
}
pub fn add_function_call_key(
mut self,
public_key: PublicKey,
receiver_id: impl TryIntoAccountId,
method_names: Vec<String>,
allowance: Option<NearToken>,
) -> Self {
let receiver_id = receiver_id
.try_into_account_id()
.expect("invalid account ID");
self.actions.push(Action::add_function_call_key(
public_key,
receiver_id,
method_names,
allowance,
));
self
}
pub fn delete_key(mut self, public_key: PublicKey) -> Self {
self.actions.push(Action::delete_key(public_key));
self
}
pub fn delete_account(mut self, beneficiary_id: impl TryIntoAccountId) -> Self {
let beneficiary_id = beneficiary_id
.try_into_account_id()
.expect("invalid account ID");
self.actions.push(Action::delete_account(beneficiary_id));
self
}
pub fn stake(mut self, amount: impl IntoNearToken, public_key: PublicKey) -> Self {
let amount = amount
.into_near_token()
.expect("invalid stake amount - use NearToken::from_str() for user input");
self.actions.push(Action::stake(amount, public_key));
self
}
pub fn signed_delegate_action(mut self, signed_delegate: SignedDelegateAction) -> Self {
self.receiver_id = signed_delegate.sender_id().clone();
self.actions.push(Action::delegate(signed_delegate));
self
}
pub async fn delegate(self, options: DelegateOptions) -> Result<DelegateResult, Error> {
if self.actions.is_empty() {
return Err(Error::InvalidTransaction(
"Delegate action requires at least one action".to_string(),
));
}
for action in &self.actions {
if matches!(action, Action::Delegate(_)) {
return Err(Error::InvalidTransaction(
"Delegate actions cannot contain nested signed delegate actions".to_string(),
));
}
}
let signer = self
.signer_override
.as_ref()
.or(self.signer.as_ref())
.ok_or(Error::NoSigner)?;
let sender_id = signer.account_id().clone();
let key = signer.key();
let public_key = key.public_key().clone();
let nonce = if let Some(n) = options.nonce {
n
} else {
let access_key = self
.rpc
.view_access_key(
&sender_id,
&public_key,
BlockReference::Finality(Finality::Optimistic),
)
.await?;
access_key.nonce + 1
};
let max_block_height = if let Some(h) = options.max_block_height {
h
} else {
let status = self.rpc.status().await?;
let offset = options.block_height_offset.unwrap_or(200);
status.sync_info.latest_block_height + offset
};
let delegate_actions: Vec<NonDelegateAction> = self
.actions
.into_iter()
.filter_map(NonDelegateAction::from_action)
.collect();
let delegate_action = DelegateAction {
sender_id,
receiver_id: self.receiver_id,
actions: delegate_actions,
nonce,
max_block_height,
public_key: public_key.clone(),
};
let hash = delegate_action.get_hash();
let signature = key.sign(hash.as_bytes()).await?;
let signed_delegate_action = delegate_action.sign(signature);
let payload = signed_delegate_action.to_base64();
Ok(DelegateResult {
signed_delegate_action,
payload,
})
}
pub fn publish(mut self, code: impl Into<Vec<u8>>, mode: PublishMode) -> Self {
self.actions.push(Action::publish(code.into(), mode));
self
}
pub fn deploy_from(mut self, contract_ref: impl GlobalContractRef) -> Self {
let identifier = contract_ref.into_identifier();
self.actions.push(match identifier {
GlobalContractIdentifier::CodeHash(hash) => Action::deploy_from_hash(hash),
GlobalContractIdentifier::AccountId(id) => Action::deploy_from_account(id),
});
self
}
pub fn state_init(
mut self,
state_init: DeterministicAccountStateInit,
deposit: impl IntoNearToken,
) -> Self {
let deposit = deposit
.into_near_token()
.expect("invalid deposit amount - use NearToken::from_str() for user input");
self.receiver_id = state_init.derive_account_id();
self.actions.push(Action::state_init(state_init, deposit));
self
}
pub fn add_action(mut self, action: impl Into<Action>) -> Self {
self.actions.push(action.into());
self
}
pub fn sign_with(mut self, signer: impl Signer + 'static) -> Self {
self.signer_override = Some(Arc::new(signer));
self
}
pub fn wait_until(mut self, status: TxExecutionStatus) -> Self {
self.wait_until = status;
self
}
pub fn max_nonce_retries(mut self, retries: u32) -> Self {
self.max_nonce_retries = retries;
self
}
pub async fn sign(self) -> Result<SignedTransaction, Error> {
if self.actions.is_empty() {
return Err(Error::InvalidTransaction(
"Transaction must have at least one action".to_string(),
));
}
let signer = self
.signer_override
.or(self.signer)
.ok_or(Error::NoSigner)?;
let signer_id = signer.account_id().clone();
let action_count = self.actions.len();
let span = tracing::info_span!(
"sign_transaction",
sender = %signer_id,
receiver = %self.receiver_id,
action_count,
);
async move {
let key = signer.key();
let public_key = key.public_key().clone();
let access_key = self
.rpc
.view_access_key(
&signer_id,
&public_key,
BlockReference::Finality(Finality::Final),
)
.await?;
let block_hash = access_key.block_hash;
let network = self.rpc.url().to_string();
let nonce = nonce_manager().next(
network,
signer_id.clone(),
public_key.clone(),
access_key.nonce,
);
let tx = Transaction::new(
signer_id,
public_key,
nonce,
self.receiver_id,
block_hash,
self.actions,
);
let tx_hash = tx.get_hash();
let signature = key.sign(tx_hash.as_bytes()).await?;
tracing::debug!(tx_hash = %tx_hash, nonce, "Transaction signed");
Ok(SignedTransaction {
transaction: tx,
signature,
})
}
.instrument(span)
.await
}
pub async fn sign_offline(
self,
block_hash: CryptoHash,
nonce: u64,
) -> Result<SignedTransaction, Error> {
if self.actions.is_empty() {
return Err(Error::InvalidTransaction(
"Transaction must have at least one action".to_string(),
));
}
let signer = self
.signer_override
.or(self.signer)
.ok_or(Error::NoSigner)?;
let signer_id = signer.account_id().clone();
let key = signer.key();
let public_key = key.public_key().clone();
let tx = Transaction::new(
signer_id,
public_key,
nonce,
self.receiver_id,
block_hash,
self.actions,
);
let signature = key.sign(tx.get_hash().as_bytes()).await?;
Ok(SignedTransaction {
transaction: tx,
signature,
})
}
pub fn send(self) -> TransactionSend {
TransactionSend { builder: self }
}
}
pub struct FunctionCall {
method: String,
args: Vec<u8>,
gas: Gas,
deposit: NearToken,
}
impl fmt::Debug for FunctionCall {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("FunctionCall")
.field("method", &self.method)
.field("args_len", &self.args.len())
.field("gas", &self.gas)
.field("deposit", &self.deposit)
.finish()
}
}
impl FunctionCall {
pub fn new(method: impl Into<String>) -> Self {
Self {
method: method.into(),
args: Vec::new(),
gas: Gas::from_tgas(30),
deposit: NearToken::ZERO,
}
}
pub fn args(mut self, args: impl serde::Serialize) -> Self {
self.args = serde_json::to_vec(&args).unwrap_or_default();
self
}
pub fn args_raw(mut self, args: Vec<u8>) -> Self {
self.args = args;
self
}
pub fn args_borsh(mut self, args: impl borsh::BorshSerialize) -> Self {
self.args = borsh::to_vec(&args).unwrap_or_default();
self
}
pub fn gas(mut self, gas: impl IntoGas) -> Self {
self.gas = gas
.into_gas()
.expect("invalid gas format - use Gas::from_str() for user input");
self
}
pub fn deposit(mut self, amount: impl IntoNearToken) -> Self {
self.deposit = amount
.into_near_token()
.expect("invalid deposit amount - use NearToken::from_str() for user input");
self
}
}
impl From<FunctionCall> for Action {
fn from(call: FunctionCall) -> Self {
Action::function_call(call.method, call.args, call.gas, call.deposit)
}
}
pub struct CallBuilder {
builder: TransactionBuilder,
call: FunctionCall,
}
impl fmt::Debug for CallBuilder {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("CallBuilder")
.field("call", &self.call)
.field("builder", &self.builder)
.finish()
}
}
impl CallBuilder {
fn new(builder: TransactionBuilder, method: String) -> Self {
Self {
builder,
call: FunctionCall::new(method),
}
}
pub fn args<A: serde::Serialize>(mut self, args: A) -> Self {
self.call = self.call.args(args);
self
}
pub fn args_raw(mut self, args: Vec<u8>) -> Self {
self.call = self.call.args_raw(args);
self
}
pub fn args_borsh<A: borsh::BorshSerialize>(mut self, args: A) -> Self {
self.call = self.call.args_borsh(args);
self
}
pub fn gas(mut self, gas: impl IntoGas) -> Self {
self.call = self.call.gas(gas);
self
}
pub fn deposit(mut self, amount: impl IntoNearToken) -> Self {
self.call = self.call.deposit(amount);
self
}
pub fn into_action(self) -> Action {
assert!(
self.builder.actions.is_empty(),
"into_action() discards {} previously accumulated action(s) — \
use .finish() to keep them in the transaction",
self.builder.actions.len(),
);
self.call.into()
}
pub fn finish(self) -> TransactionBuilder {
self.builder.add_action(self.call)
}
pub fn add_action(self, action: impl Into<Action>) -> TransactionBuilder {
self.finish().add_action(action)
}
pub fn call(self, method: &str) -> CallBuilder {
self.finish().call(method)
}
pub fn create_account(self) -> TransactionBuilder {
self.finish().create_account()
}
pub fn transfer(self, amount: impl IntoNearToken) -> TransactionBuilder {
self.finish().transfer(amount)
}
pub fn deploy(self, code: impl Into<Vec<u8>>) -> TransactionBuilder {
self.finish().deploy(code)
}
pub fn add_full_access_key(self, public_key: PublicKey) -> TransactionBuilder {
self.finish().add_full_access_key(public_key)
}
pub fn add_function_call_key(
self,
public_key: PublicKey,
receiver_id: impl TryIntoAccountId,
method_names: Vec<String>,
allowance: Option<NearToken>,
) -> TransactionBuilder {
self.finish()
.add_function_call_key(public_key, receiver_id, method_names, allowance)
}
pub fn delete_key(self, public_key: PublicKey) -> TransactionBuilder {
self.finish().delete_key(public_key)
}
pub fn delete_account(self, beneficiary_id: impl TryIntoAccountId) -> TransactionBuilder {
self.finish().delete_account(beneficiary_id)
}
pub fn stake(self, amount: impl IntoNearToken, public_key: PublicKey) -> TransactionBuilder {
self.finish().stake(amount, public_key)
}
pub fn publish(self, code: impl Into<Vec<u8>>, mode: PublishMode) -> TransactionBuilder {
self.finish().publish(code, mode)
}
pub fn deploy_from(self, contract_ref: impl GlobalContractRef) -> TransactionBuilder {
self.finish().deploy_from(contract_ref)
}
pub fn state_init(
self,
state_init: DeterministicAccountStateInit,
deposit: impl IntoNearToken,
) -> TransactionBuilder {
self.finish().state_init(state_init, deposit)
}
pub fn sign_with(self, signer: impl Signer + 'static) -> TransactionBuilder {
self.finish().sign_with(signer)
}
pub fn wait_until(self, status: TxExecutionStatus) -> TransactionBuilder {
self.finish().wait_until(status)
}
pub fn max_nonce_retries(self, retries: u32) -> TransactionBuilder {
self.finish().max_nonce_retries(retries)
}
pub async fn delegate(self, options: DelegateOptions) -> Result<DelegateResult, crate::Error> {
self.finish().delegate(options).await
}
pub async fn sign_offline(
self,
block_hash: CryptoHash,
nonce: u64,
) -> Result<SignedTransaction, Error> {
self.finish().sign_offline(block_hash, nonce).await
}
pub async fn sign(self) -> Result<SignedTransaction, Error> {
self.finish().sign().await
}
pub fn send(self) -> TransactionSend {
self.finish().send()
}
}
impl IntoFuture for CallBuilder {
type Output = Result<FinalExecutionOutcome, Error>;
type IntoFuture = Pin<Box<dyn Future<Output = Self::Output> + Send>>;
fn into_future(self) -> Self::IntoFuture {
self.send().into_future()
}
}
pub struct TransactionSend {
builder: TransactionBuilder,
}
impl TransactionSend {
pub fn wait_until(mut self, status: TxExecutionStatus) -> Self {
self.builder.wait_until = status;
self
}
pub fn max_nonce_retries(mut self, retries: u32) -> Self {
self.builder.max_nonce_retries = retries;
self
}
}
impl IntoFuture for TransactionSend {
type Output = Result<FinalExecutionOutcome, Error>;
type IntoFuture = Pin<Box<dyn Future<Output = Self::Output> + Send>>;
fn into_future(self) -> Self::IntoFuture {
Box::pin(async move {
let builder = self.builder;
if builder.actions.is_empty() {
return Err(Error::InvalidTransaction(
"Transaction must have at least one action".to_string(),
));
}
let signer = builder
.signer_override
.as_ref()
.or(builder.signer.as_ref())
.ok_or(Error::NoSigner)?;
let signer_id = signer.account_id().clone();
let span = tracing::info_span!(
"send_transaction",
sender = %signer_id,
receiver = %builder.receiver_id,
action_count = builder.actions.len(),
);
async move {
let max_nonce_retries = builder.max_nonce_retries;
let network = builder.rpc.url().to_string();
let mut last_error: Option<Error> = None;
let mut last_ak_nonce: Option<u64> = None;
for attempt in 0..=max_nonce_retries {
let key = signer.key();
let public_key = key.public_key().clone();
let access_key = builder
.rpc
.view_access_key(
&signer_id,
&public_key,
BlockReference::Finality(Finality::Final),
)
.await?;
let block_hash = access_key.block_hash;
let nonce = nonce_manager().next(
network.clone(),
signer_id.clone(),
public_key.clone(),
last_ak_nonce.take().unwrap_or(access_key.nonce),
);
let tx = Transaction::new(
signer_id.clone(),
public_key.clone(),
nonce,
builder.receiver_id.clone(),
block_hash,
builder.actions.clone(),
);
let signature = match key.sign(tx.get_hash().as_bytes()).await {
Ok(sig) => sig,
Err(e) => return Err(Error::Signing(e)),
};
let signed_tx = crate::types::SignedTransaction {
transaction: tx,
signature,
};
match builder.rpc.send_tx(&signed_tx, builder.wait_until).await {
Ok(response) => {
let outcome = response.outcome.ok_or_else(|| {
Error::InvalidTransaction(format!(
"Transaction {} submitted with wait_until={:?} but no execution \
outcome was returned. Use rpc().send_tx() for fire-and-forget \
submission.",
response.transaction_hash, builder.wait_until,
))
})?;
use crate::types::{FinalExecutionStatus, TxExecutionError};
match outcome.status {
FinalExecutionStatus::Failure(
TxExecutionError::InvalidTxError(e),
) => {
return Err(Error::InvalidTx(Box::new(e)));
}
_ => return Ok(outcome),
}
}
Err(RpcError::InvalidTx(
crate::types::InvalidTxError::InvalidNonce { tx_nonce, ak_nonce },
)) if attempt < max_nonce_retries => {
tracing::warn!(
tx_nonce = tx_nonce,
ak_nonce = ak_nonce,
attempt = attempt + 1,
"Invalid nonce, retrying"
);
last_ak_nonce = Some(ak_nonce);
last_error = Some(Error::InvalidTx(Box::new(
crate::types::InvalidTxError::InvalidNonce { tx_nonce, ak_nonce },
)));
continue;
}
Err(RpcError::InvalidTx(crate::types::InvalidTxError::Expired))
if attempt + 1 < max_nonce_retries =>
{
tracing::warn!(
attempt = attempt + 1,
"Transaction expired (stale block hash), retrying with fresh block hash"
);
last_error = Some(Error::InvalidTx(Box::new(
crate::types::InvalidTxError::Expired,
)));
continue;
}
Err(e) => {
tracing::error!(error = %e, "Transaction send failed");
return Err(e.into());
}
}
}
Err(last_error.unwrap_or_else(|| {
Error::InvalidTransaction("Unknown error during transaction send".to_string())
}))
}
.instrument(span)
.await
})
}
}
impl IntoFuture for TransactionBuilder {
type Output = Result<FinalExecutionOutcome, Error>;
type IntoFuture = Pin<Box<dyn Future<Output = Self::Output> + Send>>;
fn into_future(self) -> Self::IntoFuture {
self.send().into_future()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_builder() -> TransactionBuilder {
let rpc = Arc::new(RpcClient::new("https://rpc.testnet.near.org"));
let receiver: AccountId = "contract.testnet".parse().unwrap();
TransactionBuilder::new(rpc, None, receiver, 0)
}
#[test]
fn add_action_appends_to_transaction() {
let action = Action::function_call(
"do_something",
serde_json::to_vec(&serde_json::json!({ "key": "value" })).unwrap(),
Gas::from_tgas(30),
NearToken::ZERO,
);
let builder = test_builder().add_action(action);
assert_eq!(builder.actions.len(), 1);
}
#[test]
fn add_action_chains_with_other_actions() {
let call_action =
Action::function_call("init", Vec::new(), Gas::from_tgas(10), NearToken::ZERO);
let builder = test_builder()
.create_account()
.transfer(NearToken::from_near(5))
.add_action(call_action);
assert_eq!(builder.actions.len(), 3);
}
#[test]
fn add_action_works_after_call_builder() {
let extra_action = Action::transfer(NearToken::from_near(1));
let builder = test_builder()
.call("setup")
.args(serde_json::json!({ "admin": "alice.testnet" }))
.gas(Gas::from_tgas(50))
.add_action(extra_action);
assert_eq!(builder.actions.len(), 2);
}
#[test]
fn function_call_into_action() {
let call = FunctionCall::new("init")
.args(serde_json::json!({"owner": "alice.testnet"}))
.gas(Gas::from_tgas(50))
.deposit(NearToken::from_near(1));
let action: Action = call.into();
match &action {
Action::FunctionCall(fc) => {
assert_eq!(fc.method_name, "init");
assert_eq!(
fc.args,
serde_json::to_vec(&serde_json::json!({"owner": "alice.testnet"})).unwrap()
);
assert_eq!(fc.gas, Gas::from_tgas(50));
assert_eq!(fc.deposit, NearToken::from_near(1));
}
other => panic!("expected FunctionCall, got {:?}", other),
}
}
#[test]
fn function_call_defaults() {
let call = FunctionCall::new("method");
let action: Action = call.into();
match &action {
Action::FunctionCall(fc) => {
assert_eq!(fc.method_name, "method");
assert!(fc.args.is_empty());
assert_eq!(fc.gas, Gas::from_tgas(30));
assert_eq!(fc.deposit, NearToken::ZERO);
}
other => panic!("expected FunctionCall, got {:?}", other),
}
}
#[test]
fn function_call_compose_into_transaction() {
let init = FunctionCall::new("init")
.args(serde_json::json!({"owner": "alice.testnet"}))
.gas(Gas::from_tgas(50));
let notify = FunctionCall::new("notify").args(serde_json::json!({"msg": "done"}));
let builder = test_builder()
.deploy(vec![0u8])
.add_action(init)
.add_action(notify);
assert_eq!(builder.actions.len(), 3);
}
#[test]
fn function_call_dynamic_loop_composition() {
let methods = vec!["step1", "step2", "step3"];
let mut tx = test_builder();
for method in methods {
tx = tx.add_action(FunctionCall::new(method));
}
assert_eq!(tx.actions.len(), 3);
}
#[test]
fn call_builder_into_action() {
let action = test_builder()
.call("setup")
.args(serde_json::json!({"admin": "alice.testnet"}))
.gas(Gas::from_tgas(50))
.deposit(NearToken::from_near(1))
.into_action();
match &action {
Action::FunctionCall(fc) => {
assert_eq!(fc.method_name, "setup");
assert_eq!(fc.gas, Gas::from_tgas(50));
assert_eq!(fc.deposit, NearToken::from_near(1));
}
other => panic!("expected FunctionCall, got {:?}", other),
}
}
#[test]
fn call_builder_into_action_compose() {
let action1 = test_builder()
.call("method_a")
.gas(Gas::from_tgas(50))
.into_action();
let action2 = test_builder()
.call("method_b")
.deposit(NearToken::from_near(1))
.into_action();
let builder = test_builder().add_action(action1).add_action(action2);
assert_eq!(builder.actions.len(), 2);
}
}