use crate::utils::base64;
use crate::utils::hex;
use crate::utils::ids::{AccountIndex, ApiKeyIndex, MarketId, WIDTH_48};
use crate::utils::json::Json;
use crate::utils::sign::fp5::Fp5;
use crate::utils::sign::goldilocks::Fp;
use crate::utils::sign::poseidon2::hash_to_fp5;
use crate::utils::sign::schnorr::{SecretKey, Signature};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum TxType {
ChangePubKey,
CreateSubAccount,
CreatePublicPool,
UpdatePublicPool,
Transfer,
Withdraw,
CreateOrder,
CancelOrder,
CancelAllOrders,
ModifyOrder,
MintShares,
BurnShares,
UpdateLeverage,
CreateGroupedOrders,
UpdateMargin,
StakeAssets,
UnstakeAssets,
UpdateAccountConfig,
UpdateAccountAssetConfig,
ApproveIntegrator,
BinaryOptionsCreateMarket,
BinaryOptionsSettleMarket,
BinaryOptionsUpdateMarket,
}
impl TxType {
pub const ALL: [TxType; 23] = [
TxType::ChangePubKey,
TxType::CreateSubAccount,
TxType::CreatePublicPool,
TxType::UpdatePublicPool,
TxType::Transfer,
TxType::Withdraw,
TxType::CreateOrder,
TxType::CancelOrder,
TxType::CancelAllOrders,
TxType::ModifyOrder,
TxType::MintShares,
TxType::BurnShares,
TxType::UpdateLeverage,
TxType::CreateGroupedOrders,
TxType::UpdateMargin,
TxType::StakeAssets,
TxType::UnstakeAssets,
TxType::UpdateAccountConfig,
TxType::UpdateAccountAssetConfig,
TxType::ApproveIntegrator,
TxType::BinaryOptionsCreateMarket,
TxType::BinaryOptionsSettleMarket,
TxType::BinaryOptionsUpdateMarket,
];
#[must_use]
pub const fn code(self) -> u8 {
match self {
TxType::ChangePubKey => 8,
TxType::CreateSubAccount => 9,
TxType::CreatePublicPool => 10,
TxType::UpdatePublicPool => 11,
TxType::Transfer => 12,
TxType::Withdraw => 13,
TxType::CreateOrder => 14,
TxType::CancelOrder => 15,
TxType::CancelAllOrders => 16,
TxType::ModifyOrder => 17,
TxType::MintShares => 18,
TxType::BurnShares => 19,
TxType::UpdateLeverage => 20,
TxType::CreateGroupedOrders => 28,
TxType::UpdateMargin => 29,
TxType::StakeAssets => 35,
TxType::UnstakeAssets => 36,
TxType::UpdateAccountConfig => 41,
TxType::UpdateAccountAssetConfig => 42,
TxType::ApproveIntegrator => 45,
TxType::BinaryOptionsCreateMarket => 49,
TxType::BinaryOptionsSettleMarket => 50,
TxType::BinaryOptionsUpdateMarket => 52,
}
}
#[must_use]
pub fn from_code(code: u8) -> Option<TxType> {
TxType::ALL.into_iter().find(|tx_type| tx_type.code() == code)
}
#[must_use]
pub const fn spends_quota(self) -> bool {
matches!(
self,
TxType::CreateOrder | TxType::CancelAllOrders | TxType::ModifyOrder | TxType::CreateGroupedOrders
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Envelope {
pub account: AccountIndex,
pub api_key: ApiKeyIndex,
pub nonce: u64,
pub expired_at: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("{field}: {why}")]
pub struct BadTx {
pub field: &'static str,
pub why: String,
}
impl BadTx {
#[must_use]
pub fn new(field: &'static str, why: impl Into<String>) -> BadTx {
BadTx { field, why: why.into() }
}
}
pub const FEE_TICK: u32 = 1_000_000;
const INTEGRATOR_ACCOUNT: u8 = 1;
const INTEGRATOR_TAKER_FEE: u8 = 2;
const INTEGRATOR_MAKER_FEE: u8 = 3;
const SKIP_NONCE: u8 = 4;
const CANCEL_ALL_MARKET: u8 = 5;
const SELF_TRADE_BEHAVIOR: u8 = 6;
const SELF_TRADE_EQUALITY: u8 = 7;
const ORDER_VERSION: u8 = 8;
pub const MOST_ATTRIBUTES: usize = 4;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum SelfTrade {
#[default]
ExpireMaker,
ExpireTaker,
ExpireBoth,
Reduce,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum SelfTradeScope {
#[default]
Account,
Owner,
}
impl SelfTrade {
#[must_use]
pub const fn code(self) -> u8 {
match self {
SelfTrade::ExpireMaker => 0,
SelfTrade::ExpireTaker => 1,
SelfTrade::ExpireBoth => 2,
SelfTrade::Reduce => 3,
}
}
}
impl SelfTradeScope {
#[must_use]
pub const fn code(self) -> u8 {
match self {
SelfTradeScope::Account => 0,
SelfTradeScope::Owner => 1,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Attributes {
integrator: Option<AccountIndex>,
taker_fee: u32,
maker_fee: u32,
skip_nonce: bool,
cancel_market: Option<MarketId>,
self_trade: SelfTrade,
scope: SelfTradeScope,
order_version: u64,
}
impl Attributes {
pub const NONE: Attributes = Attributes {
integrator: None,
taker_fee: 0,
maker_fee: 0,
skip_nonce: false,
cancel_market: None,
self_trade: SelfTrade::ExpireMaker,
scope: SelfTradeScope::Account,
order_version: 0,
};
#[must_use]
pub const fn integrator(mut self, account: AccountIndex, taker_fee: u32, maker_fee: u32) -> Attributes {
self.integrator = Some(account);
self.taker_fee = taker_fee;
self.maker_fee = maker_fee;
self
}
#[must_use]
pub const fn skip_nonce(mut self) -> Attributes {
self.skip_nonce = true;
self
}
#[must_use]
pub const fn cancel_market(mut self, market: MarketId) -> Attributes {
self.cancel_market = Some(market);
self
}
#[must_use]
pub const fn self_trade(mut self, behavior: SelfTrade, scope: SelfTradeScope) -> Attributes {
self.self_trade = behavior;
self.scope = scope;
self
}
#[must_use]
pub const fn order_version(mut self, version: u64) -> Attributes {
self.order_version = version;
self
}
#[must_use]
pub const fn integrator_account(&self) -> Option<AccountIndex> {
self.integrator
}
#[must_use]
pub const fn fees(&self) -> (u32, u32) {
(self.taker_fee, self.maker_fee)
}
#[must_use]
pub fn is_none(&self) -> bool {
self.entries().is_empty()
}
#[must_use]
pub fn entries(&self) -> Vec<(u8, u64)> {
let integrator = self.integrator.map(AccountIndex::get).filter(|index| *index != 0);
[
integrator.map(|index| (INTEGRATOR_ACCOUNT, index)),
(self.taker_fee != 0).then_some((INTEGRATOR_TAKER_FEE, u64::from(self.taker_fee))),
(self.maker_fee != 0).then_some((INTEGRATOR_MAKER_FEE, u64::from(self.maker_fee))),
self.skip_nonce.then_some((SKIP_NONCE, 1)),
self.cancel_market.map(|market| (CANCEL_ALL_MARKET, market.get())),
(self.self_trade != SelfTrade::ExpireMaker)
.then_some((SELF_TRADE_BEHAVIOR, u64::from(self.self_trade.code()))),
(self.scope != SelfTradeScope::Account).then_some((SELF_TRADE_EQUALITY, u64::from(self.scope.code()))),
(self.order_version != 0).then_some((ORDER_VERSION, self.order_version)),
]
.into_iter()
.flatten()
.collect()
}
pub fn check(&self) -> Result<(), BadTx> {
let entries = self.entries();
if entries.len() > MOST_ATTRIBUTES {
return Err(BadTx::new(
"L2TxAttributes",
format!("at most {MOST_ATTRIBUTES} attributes ride on one transaction"),
));
}
if self.taker_fee > FEE_TICK || self.maker_fee > FEE_TICK {
return Err(BadTx::new("L2TxAttributes", "an integrator fee is at most 1,000,000, all of the notional"));
}
if self.order_version > WIDTH_48 {
return Err(BadTx::new("L2TxAttributes", "an order version is below 2^48"));
}
let fees = self.taker_fee != 0 || self.maker_fee != 0;
let integrator = self.integrator.is_some_and(|index| index.get() != 0);
if fees && !integrator {
return Err(BadTx::new("L2TxAttributes", "integrator fees need the integrator's account"));
}
let self_trade = self.self_trade != SelfTrade::ExpireMaker || self.scope != SelfTradeScope::Account;
if fees && self_trade {
return Err(BadTx::new("L2TxAttributes", "self-trade handling does not combine with integrator fees"));
}
if self.self_trade == SelfTrade::Reduce && self.scope == SelfTradeScope::Owner {
return Err(BadTx::new("L2TxAttributes", "reduce is not allowed across an owner's accounts"));
}
Ok(())
}
fn hash(&self) -> Fp5 {
let mut elements = [Fp::ZERO; 2 * MOST_ATTRIBUTES];
for (slot, (kind, value)) in elements.chunks_exact_mut(2).zip(self.entries()) {
slot[0] = Fp::new(u64::from(kind));
slot[1] = Fp::new(value);
}
hash_to_fp5(&elements)
}
fn aggregate(&self, tx_hash: Fp5) -> Fp5 {
if self.is_none() {
return tx_hash;
}
let mut both = [Fp::ZERO; 10];
both[..5].copy_from_slice(&tx_hash.0);
both[5..].copy_from_slice(&self.hash().0);
hash_to_fp5(&both)
}
#[must_use]
pub fn json(&self) -> Json {
let entries = self.entries();
if entries.is_empty() {
return Json::Null;
}
Json::Object(entries.into_iter().map(|(kind, value)| (kind.to_string(), number(value))).collect())
}
}
pub trait Tx {
fn tx_type(&self) -> TxType;
fn hash_fields(&self) -> Vec<Fp>;
fn info_fields(&self) -> Vec<(&'static str, Json)>;
fn account_key(&self) -> &'static str {
"AccountIndex"
}
fn trailing_l1_sig(&self) -> bool {
false
}
fn attributes(&self) -> Attributes {
Attributes::NONE
}
fn l1_message(&self, _envelope: &Envelope, _chain_id: u32) -> Option<String> {
None
}
fn check(&self) -> Result<(), BadTx> {
Ok(())
}
fn check_signing(&self, _chain_id: u32, _envelope: &Envelope) -> Result<(), BadTx> {
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct TxHash(Fp5);
impl TxHash {
#[must_use]
pub const fn element(&self) -> Fp5 {
self.0
}
#[must_use]
pub fn to_hex(&self) -> String {
hex::encode(&self.0.to_le_bytes())
}
}
impl std::fmt::Display for TxHash {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.to_hex())
}
}
#[must_use]
pub fn tx_hash(tx: &(impl Tx + ?Sized), chain_id: u32, envelope: &Envelope) -> TxHash {
let mut elements = vec![
Fp::new(u64::from(chain_id)),
Fp::new(u64::from(tx.tx_type().code())),
Fp::new(envelope.nonce),
Fp::new(envelope.expired_at),
Fp::new(envelope.account.get()),
Fp::new(u64::from(envelope.api_key.get())),
];
elements.extend(tx.hash_fields());
TxHash(tx.attributes().aggregate(hash_to_fp5(&elements)))
}
fn check_envelope(envelope: &Envelope) -> Result<(), BadTx> {
if envelope.expired_at > WIDTH_48 {
return Err(BadTx::new("ExpiredAt", "an expiry is unix milliseconds below 2^48"));
}
if envelope.nonce > WIDTH_48 {
return Err(BadTx::new("Nonce", "a nonce is below 2^48"));
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Signed {
tx_type: TxType,
hash: TxHash,
nonce: u64,
info: Vec<(String, Json)>,
message: Option<String>,
}
impl Signed {
#[must_use]
pub const fn tx_type(&self) -> TxType {
self.tx_type
}
#[must_use]
pub const fn hash(&self) -> TxHash {
self.hash
}
#[must_use]
pub const fn nonce(&self) -> u64 {
self.nonce
}
#[must_use]
pub fn tx_info(&self) -> String {
Json::Object(self.info.clone()).compact()
}
#[must_use]
pub fn l1_message(&self) -> Option<&str> {
self.message.as_deref()
}
#[must_use]
pub fn tx_info_with_l1(&self, l1_signature: &str) -> Option<String> {
let mut info = self.info.clone();
let (_, value) = info.iter_mut().find(|(key, _)| key == "L1Sig")?;
*value = Json::String(l1_signature.to_owned());
Some(Json::Object(info).compact())
}
#[must_use]
pub fn signature(&self) -> Option<Signature> {
let text = self.info.iter().find(|(key, _)| key == "Sig").and_then(|(_, value)| value.as_str())?;
let bytes: [u8; 80] = base64::decode(text).ok()?.try_into().ok()?;
Some(Signature::from_bytes(bytes))
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum SignTxError {
#[error(transparent)]
Bad(#[from] BadTx),
#[error("{0}")]
NoRandomness(String),
#[error("the signature does not verify; nothing was sent")]
Unverified,
}
pub fn sign_drawing<E: std::fmt::Display>(
tx: &(impl Tx + ?Sized),
chain_id: u32,
envelope: &Envelope,
key: &SecretKey,
draw: impl FnMut(&mut [u8; 40]) -> Result<(), E>,
) -> Result<Signed, SignTxError> {
tx.check()?;
tx.check_signing(chain_id, envelope)?;
tx.attributes().check()?;
check_envelope(envelope)?;
let hash = tx_hash(tx, chain_id, envelope);
let signature =
key.sign_drawing(hash.element(), draw).map_err(|error| SignTxError::NoRandomness(error.to_string()))?;
if !key.verifies(hash.element(), &signature) {
return Err(SignTxError::Unverified);
}
Ok(Signed {
tx_type: tx.tx_type(),
hash,
nonce: envelope.nonce,
info: info(tx, envelope, &signature),
message: tx.l1_message(envelope, chain_id),
})
}
#[cfg(feature = "sign")]
pub fn sign(
tx: &(impl Tx + ?Sized),
chain_id: u32,
envelope: &Envelope,
key: &SecretKey,
) -> Result<Signed, SignTxError> {
sign_drawing(tx, chain_id, envelope, key, crate::utils::sign::schnorr::os_draw)
}
fn info(tx: &(impl Tx + ?Sized), envelope: &Envelope, signature: &Signature) -> Vec<(String, Json)> {
let mut fields: Vec<(String, Json)> = vec![
(tx.account_key().to_owned(), number(envelope.account.get())),
("ApiKeyIndex".to_owned(), number(envelope.api_key.get())),
];
fields.extend(tx.info_fields().into_iter().map(|(key, value)| (key.to_owned(), value)));
fields.push(("ExpiredAt".to_owned(), number(envelope.expired_at)));
fields.push(("Nonce".to_owned(), number(envelope.nonce)));
fields.push(("Sig".to_owned(), Json::String(base64::encode(signature.bytes()))));
if tx.trailing_l1_sig() {
fields.push(("L1Sig".to_owned(), Json::String(String::new())));
}
fields.push(("L2TxAttributes".to_owned(), tx.attributes().json()));
fields
}
pub fn number(value: impl Into<i128>) -> Json {
Json::Number(value.into().to_string())
}
#[must_use]
pub fn byte_list(bytes: &[u8]) -> Json {
Json::Array(bytes.iter().map(|byte| number(*byte)).collect())
}
#[must_use]
pub fn byte_string(bytes: &[u8]) -> Json {
Json::String(base64::encode(bytes))
}
#[must_use]
pub fn hex16(value: u64) -> String {
format!("0x{value:016x}")
}
#[must_use]
pub fn element(value: impl Into<u64>) -> Fp {
Fp::new(value.into())
}
#[cfg(test)]
#[path = "tests/tx.rs"]
mod tests;