Skip to content
Open
213 changes: 155 additions & 58 deletions Cargo.lock

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion contract/universal-account/examples/02_payload.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ pub fn main() {
index: U64(0),
nonce: U64(1),
},
account_id: "my-universal-account.testnet".parse().unwrap(),
account_id: "default-18843764340.gh-275.templar-in-training.testnet"
.parse()
.unwrap(),
payload: vec![Transaction {
receiver_id: "alice.testnet".parse().unwrap(),
actions: vec![Action::Transfer {
Expand Down
1 change: 1 addition & 0 deletions service/relayer/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ tower.workspace = true
tower-http.workspace = true
tracing-subscriber = { workspace = true, features = ["env-filter", "json"] }
tracing = { workspace = true, features = ["attributes"] }
reqwest = {version = "0.12.24", features = ["json"]}

[dev-dependencies]
near-workspaces = { workspace = true, features = ["experimental"] }
Expand Down
56 changes: 47 additions & 9 deletions service/relayer/src/app/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ pub struct Configuration {
#[clap(flatten)]
pub ua: UniversalAccount,
#[clap(flatten)]
pub pyth: Pyth,
#[clap(flatten)]
pub cache: Cache,
/// Broom batch size.
#[arg(long, env = "BROOM_BATCH_SIZE", default_value_t = 16)]
Expand All @@ -36,6 +38,51 @@ fn duration_from_secs(s: &str) -> Result<Duration, std::num::ParseIntError> {
Ok(Duration::from_secs(u64::from_str(s)?))
}

#[derive(Args, Debug, Clone)]
pub struct Pyth {
/// Pyth Hermes API URL. See: <https://docs.pyth.network/price-feeds/core/api-reference>
#[arg(
long = "pyth-hermes-url",
env = "PYTH_HERMES_URL",
default_value_t = String::from("https://hermes-beta.pyth.network")
)]
pub hermes_url: String,
/// Do not push price updates to Pyth oracle if the last push was less
/// than this long ago, even if requested.
#[arg(
id = "pyth-refresh-secs",
long = "pyth-refresh-secs",
env = "PYTH_REFRESH_SECS",
value_parser = duration_from_secs,
default_value = "5"
)]
pub refresh: Duration,
/// Oracle ID to push price updates to.
#[arg(
id = "pyth-oracle-id",
long = "pyth-oracle-id",
env = "PYTH_ORACLE_ID",
default_value_t = AccountId::from_str("pyth-oracle.testnet").unwrap()
)]
pub oracle_id: AccountId,
/// How much gas (in units of Tgas) to attach to oracle price update calls.
#[arg(
id = "pyth-update-gas",
long = "pyth-update-gas",
env = "PYTH_UPDATE_GAS",
default_value = "300 Tgas"
)]
pub update_gas: near_sdk::Gas,
/// How much NEAR to attach as a deposit to oracle price update calls.
#[arg(
id = "pyth-update-deposit",
long = "pyth-update-deposit",
env = "PYTH_UPDATE_DEPOSIT",
default_value = "0.01 NEAR"
)]
pub update_deposit: NearToken,
}

#[derive(Args, Debug, Clone)]
pub struct Cache {
/// Refresh the cached gas price after X seconds.
Expand All @@ -56,15 +103,6 @@ pub struct Cache {
default_value = "60"
)]
pub nonce_refresh: Duration,
/// Refresh the cached protocol configuration after X seconds.
#[arg(
id = "cache-protocol-config-secs",
long = "cache-protocol-config-secs",
env = "CACHE_PROTOCOL_CONFIG_SECS",
value_parser = duration_from_secs,
default_value = "3600"
)]
pub protocol_config_refresh: Duration,
}

#[derive(Args, Debug, Clone)]
Expand Down
157 changes: 113 additions & 44 deletions service/relayer/src/app/mod.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use std::{
borrow::Borrow,
collections::{hash_map::Entry, HashMap},
collections::{hash_map::Entry, HashMap, HashSet},
future::Future,
sync::Arc,
time::Duration,
Expand Down Expand Up @@ -29,9 +29,10 @@ use crate::{
Database,
},
near::Near,
pyth::Pyth,
},
error::PreconditionError,
AccountData, AssetTransfer, AssetTransferParseError, ContractData,
error::{FunctionCallRejectionReason, PayloadRejectionReason},
AccountData, AssetTransfer, ContractData,
};

pub mod args;
Expand All @@ -43,6 +44,7 @@ pub struct App {
pub accounts: Arc<RwLock<AccountData>>,
pub relay_near: Near,
pub ua_near: Near,
pub pyth: Pyth,
pub cache: Arc<Cache>,
pub database: Database,
}
Expand All @@ -69,6 +71,8 @@ impl App {
.collect(),
);

let pyth = Pyth::new(args.pyth.clone());

#[allow(clippy::unwrap_used)]
let database = Database::new(&args.database_url, kill.clone()).unwrap();

Expand All @@ -87,26 +91,27 @@ impl App {
accounts: Arc::new(RwLock::new(AccountData::default())),
relay_near,
ua_near,
pyth,
cache: Arc::new(cache),
database,
}
}

#[tracing::instrument(skip(self), fields(gas = %gas))]
pub async fn estimate_cost_of_gas(&self, gas: u64) -> Option<NearToken> {
pub async fn estimate_cost_of_gas(&self, gas: near_sdk::Gas) -> Option<NearToken> {
const TERA: u128 = near_sdk::Gas::from_tgas(1).as_gas() as u128;

let price_per_tgas = self.cache.gas_price().await;
let result = price_per_tgas
.checked_mul(u128::from(gas))?
.checked_div(TERA);
.checked_mul(u128::from(gas.as_gas()))
.and_then(|x| x.checked_div(TERA));

tracing::debug!(cost = ?result, "Estimated gas cost");
result
}

#[allow(clippy::too_many_lines, reason = "procedural")]
#[tracing::instrument(skip(self), name = "load_markets")]
#[tracing::instrument(skip(self))]
pub async fn load_markets(&mut self) {
tracing::info!("Loading markets from registry and individual sources");
let mut markets = self.args.monitor.market.clone();
Expand Down Expand Up @@ -245,80 +250,137 @@ impl App {
///
/// - If the receiver is not known.
/// - If any of the function call actions are not allowed.
#[tracing::instrument(skip(self, accounts, calls), fields(receiver_id = %receiver_id))]
#[tracing::instrument(skip(self, accounts, contract_data, calls))]
pub fn actions_are_allowed<'a>(
&self,
receiver_id: &AccountIdRef,
accounts: &AccountData,
receiver_id: &AccountIdRef,
contract_data: &ContractData,
calls: impl IntoIterator<Item = &'a FunctionCallAction>,
) -> Result<Vec<AccountId>, PreconditionError> {
) -> Result<Vec<AccountId>, Vec<FunctionCallRejectionReason>> {
let mut other_interactions = Vec::new();

let Some(contract_data) = accounts.allowed_contract_data.get(receiver_id) else {
return Err(PreconditionError::UnknownTransactionReceiverId {
account_id: receiver_id.to_owned(),
});
};
let mut errors = vec![];

for (index, call) in calls.into_iter().enumerate() {
if !contract_data.allowed_methods.contains(&call.method_name) {
return Err(PreconditionError::UnknownFunctionName { index });
errors.push(FunctionCallRejectionReason::UnknownFunctionName {
index,
function_name: call.method_name.clone(),
});
}

if let Ok(transfer) =
AssetTransfer::parse(receiver_id.to_owned(), call).map_err(|e| match e {
AssetTransferParseError::UnknownFunctionName => {
PreconditionError::UnknownFunctionName { index }
}
AssetTransferParseError::ArgumentDeserialization => {
PreconditionError::ArgumentDeserializationFailure { index }
}
})
{
if let Ok(transfer) = AssetTransfer::parse(receiver_id.to_owned(), call) {
let market_id = transfer.token_receiver_id();
other_interactions.push(market_id.to_owned());

let Some(market_account_ids) = accounts.market_data.get(market_id) else {
return Err(PreconditionError::UnknownTransferReceiverId {
errors.push(FunctionCallRejectionReason::UnknownTransferReceiverId {
account_id: market_id.to_owned(),
index,
});
continue;
};

let msg = transfer.args.msg();
let Ok(msg) = serde_json::from_str::<DepositMsg>(msg) else {
return Err(PreconditionError::MsgDeserializationFailure {
errors.push(FunctionCallRejectionReason::MsgDeserializationFailure {
index,
msg: msg.to_string(),
});
continue;
};

#[allow(clippy::unwrap_used, reason = "DepositMsg serialization is infallible")]
if transfer.asset() == market_account_ids.borrow_asset {
if !matches!(msg, DepositMsg::Supply | DepositMsg::Repay) {
return Err(PreconditionError::InvalidMsgForAsset {
errors.push(FunctionCallRejectionReason::InvalidMsgForAsset {
index,
expected: "\"Supply\" or \"Repay\"".to_string(),
actual: serde_json::to_string(&msg).unwrap(),
});
}
} else if transfer.asset() == market_account_ids.collateral_asset {
if !matches!(msg, DepositMsg::Collateralize) {
return Err(PreconditionError::InvalidMsgForAsset {
errors.push(FunctionCallRejectionReason::InvalidMsgForAsset {
index,
expected: "\"Collateralize\"".to_string(),
actual: serde_json::to_string(&msg).unwrap(),
});
}
} else {
return Err(PreconditionError::UnknownTransactionReceiverId {
account_id: receiver_id.to_owned(),
});
// Not a standard-compliant function call
}
}
}

Ok(other_interactions)
if errors.is_empty() {
Ok(other_interactions)
} else {
Err(errors)
}
}

pub(crate) fn ua_interacted_contracts_and_gas(
&self,
accounts: &AccountData,
payload: &[templar_universal_account::transaction::Transaction],
) -> Result<(HashSet<AccountId>, near_sdk::Gas), Vec<PayloadRejectionReason>> {
let mut errors = vec![];
let mut gas = near_sdk::Gas::from_tgas(self.args.ua.execute_tgas).as_gas();
let mut interacted = HashSet::with_capacity(payload.len());

for transaction in payload {
let receiver_id = &transaction.receiver_id;
if !accounts.allowed_contract_data.contains_key(receiver_id) {
errors.push(PayloadRejectionReason::UnknownTransactionReceiverId {
account_id: receiver_id.clone(),
});
}
let mut calls = Vec::with_capacity(transaction.actions.len());
for (index, action) in transaction.actions.iter().enumerate() {
match action {
templar_universal_account::transaction::Action::FunctionCall(call)
| templar_universal_account::transaction::Action::FunctionCallWeight {
call,
..
} => calls.push(FunctionCallAction::from((**call).clone())),
_ => errors.push(PayloadRejectionReason::UnsupportedAction { index }),
}
}
let Some(contract_data) = accounts.allowed_contract_data.get(receiver_id) else {
errors.push(PayloadRejectionReason::UnknownTransactionReceiverId {
account_id: receiver_id.clone(),
});
continue;
};

interacted.insert(receiver_id.clone());
let probably_interacted = match self.actions_are_allowed(
accounts,
receiver_id,
contract_data,
calls.iter(),
) {
Ok(a) => a,
Err(e) => {
errors.push(PayloadRejectionReason::FunctionCallRejection(e));
continue;
}
};
interacted.extend(probably_interacted);
if let Some(market_data) = accounts.market_data.get(receiver_id) {
interacted.insert(market_data.oracle_id.clone());
interacted.insert(market_data.borrow_asset.contract_id().to_owned());
interacted.insert(market_data.collateral_asset.contract_id().to_owned());
}
gas += calls.iter().map(|f| f.gas).sum::<u64>();
}

if errors.is_empty() {
Ok((interacted, near_sdk::Gas::from_gas(gas)))
} else {
Err(errors)
}
}

/// Check and calculate gas for a signed delegate action.
Expand All @@ -335,20 +397,20 @@ impl App {
sender_id = %signed_delegate_action.delegate_action.sender_id,
receiver_id = %signed_delegate_action.delegate_action.receiver_id
))]
pub async fn check_and_calculate_gas(
pub async fn sda_check_and_calculate_gas(
&self,
signed_delegate_action: &SignedDelegateAction,
) -> Result<(u64, ContractData), PreconditionError> {
) -> Result<(near_sdk::Gas, ContractData), PayloadRejectionReason> {
tracing::debug!("Checking and calculating gas for delegate action");
if !signed_delegate_action.verify() {
return Err(PreconditionError::SignatureVerificationFailure);
return Err(PayloadRejectionReason::SignatureVerificationFailure);
}

let receiver_id = &signed_delegate_action.delegate_action.receiver_id;
let accounts = self.accounts.read().await;

let Some(contract_data) = accounts.allowed_contract_data.get(receiver_id).cloned() else {
return Err(PreconditionError::UnknownTransactionReceiverId {
return Err(PayloadRejectionReason::UnknownTransactionReceiverId {
account_id: receiver_id.clone(),
});
};
Expand All @@ -357,21 +419,28 @@ impl App {
let len = actions.len();
let calls = actions
.into_iter()
.try_fold(Vec::with_capacity(len), |mut v, action| {
.enumerate()
.try_fold(Vec::with_capacity(len), |mut v, (i, action)| {
if let Action::FunctionCall(fc) = action {
v.push(fc);
Ok(v)
} else {
Err((v.len(), action))
Err(i)
}
})
.map_err(|(index, action)| PreconditionError::UnsupportedAction { index, action })?;
.map_err(|index| PayloadRejectionReason::UnsupportedAction { index })?;

self.actions_are_allowed(receiver_id, &accounts, calls.iter().map(Borrow::borrow))?;
self.actions_are_allowed(
&accounts,
receiver_id,
&contract_data,
calls.iter().map(Borrow::borrow),
)
.map_err(PayloadRejectionReason::FunctionCallRejection)?;

let gas_total = calls.iter().map(|call| call.gas).sum();

Ok((gas_total, contract_data))
Ok((near_sdk::Gas::from_gas(gas_total), contract_data))
}

/// # Errors
Expand Down
1 change: 1 addition & 0 deletions service/relayer/src/client/mod.rs
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
pub mod database;
pub mod near;
pub mod pyth;
Loading