Skip to main content

metaflux_client/mip3/
auction.rs

1//! MIP-3 gas auction helpers (client-side polling + submission).
2//!
3//! The L1 implements a Dutch-style gas auction: each round
4//! emits a "credit window" during which the highest unique bidder receives
5//! a `pending_deploy_credit` that lets them submit the deploy sequence.
6//!
7//! This module wraps the auction's three client-facing primitives:
8//!
9//! - **Submit bid** → posts a signed `submit_gas_auction_bid` action.
10//! - **Check credit** → polls `info: { type: "deploy_credit", address }`
11//!   for the per-address credit count.
12//! - **Await credit** → polls with backoff until credit appears or `max_wait`
13//!   elapses.
14//!
15//! The bid kind matches the auction class — token register / perp deploy /
16//! spot pair deploy each runs as a separate auction with its own minimum.
17
18use std::time::Duration;
19
20use serde::{Deserialize, Serialize};
21use serde_json::{Value, json};
22use tokio::time::{Instant, sleep};
23
24use crate::Client;
25use crate::error::ClientError;
26use crate::wallet::{Address, Wallet};
27
28/// The class of auction the bid targets.
29///
30/// Each class has its own current price + round duration. Per-class
31/// economics: defaults are pinned but governance-tunable.
32#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
33#[serde(rename_all = "snake_case")]
34pub enum AuctionKind {
35    /// Token register auction — purchases an `asset_id` slot.
36    TokenRegister,
37    /// Perp market deploy auction — yields a `pending_deploy_credit` for
38    /// running the 8-step `perpDeploy` sequence.
39    PerpDeploy,
40    /// Spot pair deploy auction — yields a `pending_deploy_credit` for the
41    /// 4-step `spotDeploy` sequence.
42    SpotPairDeploy,
43}
44
45impl AuctionKind {
46    /// Wire discriminator (matches L1 handler).
47    #[must_use]
48    pub fn type_id(&self) -> &'static str {
49        match self {
50            Self::TokenRegister => "token_register",
51            Self::PerpDeploy => "perp_deploy",
52            Self::SpotPairDeploy => "spot_pair_deploy",
53        }
54    }
55}
56
57/// A bid into the gas auction.
58#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(rename_all = "snake_case")]
60pub struct AuctionBid {
61    /// Which auction class to bid into.
62    pub kind: AuctionKind,
63    /// Bid amount in USDC cents (smallest unit). Must exceed the current
64    /// auction price for the round to accept.
65    pub bid_amount_usdc_cents: u128,
66}
67
68/// Server response to a bid submission.
69#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
70#[serde(rename_all = "snake_case")]
71pub struct BidReceipt {
72    /// Identifier of the auction round this bid was assigned to.
73    pub round_id: u64,
74    /// Echo of the bidder's address.
75    pub bidder: Address,
76    /// Bid amount accepted (server may quote-clip).
77    pub accepted_amount_usdc_cents: u128,
78    /// Outcome string. `"accepted"`, `"replaced"`, `"underbid"`, etc.
79    pub status: String,
80}
81
82impl Client {
83    /// Submit a bid for the current gas auction round.
84    ///
85    /// The wallet signs an EIP-712 `submit_gas_auction_bid` action; the
86    /// gateway verifies the signer matches the bidder field (set internally
87    /// by the SDK to `wallet.address()`).
88    ///
89    /// # Errors
90    /// - [`ClientError::Http`] / [`ClientError::ProtocolError`] on transport.
91    /// - [`ClientError::Signature`] on signing failure.
92    pub async fn submit_gas_auction_bid(
93        &self,
94        wallet: &Wallet,
95        bid: AuctionBid,
96    ) -> Result<BidReceipt, ClientError> {
97        let action = json!({
98            "type": "submit_gas_auction_bid",
99            "bidder": wallet.address(),
100            "kind": bid.kind,
101            "bid_amount_usdc_cents": bid.bid_amount_usdc_cents,
102        });
103        self.rest().exchange().post_signed(wallet, action).await
104    }
105
106    /// Query the L1 for `address`'s outstanding deploy credits.
107    ///
108    /// Returns the count of pending credits — `0` means "no credit yet
109    /// awarded", non-zero means "you may run a deploy sequence".
110    ///
111    /// # Errors
112    /// HTTP / decode / protocol errors per [`ClientError`].
113    pub async fn check_deploy_credit(&self, address: Address) -> Result<u32, ClientError> {
114        // Returns a small wrapper struct so we don't depend on a free-form Value.
115        #[derive(Deserialize)]
116        struct CreditResp {
117            credit_count: u32,
118        }
119        let body = json!({
120            "type": "deploy_credit",
121            "address": address,
122        });
123        let resp: CreditResp = self.rest().info().raw(body).await.and_then(|v: Value| {
124            serde_json::from_value::<CreditResp>(v).map_err(ClientError::from)
125        })?;
126        Ok(resp.credit_count)
127    }
128
129    /// Block (async-sleep) until `wallet`'s address has at least one pending
130    /// deploy credit, or `max_wait` elapses.
131    ///
132    /// Uses exponential backoff capped at 5 seconds. `max_wait` must be
133    /// `Some(d)` — there is intentionally no infinite-wait variant; long-
134    /// running auctions are caller's job to compose.
135    ///
136    /// # Errors
137    /// - [`ClientError::Validation`] if `max_wait` ≤ `Duration::ZERO` or if
138    ///   the wait elapses without seeing a credit.
139    /// - Bubbles up [`Client::check_deploy_credit`] errors.
140    pub async fn await_deploy_credit(
141        &self,
142        wallet: &Wallet,
143        max_wait: Duration,
144    ) -> Result<(), ClientError> {
145        if max_wait.is_zero() {
146            return Err(ClientError::Validation(
147                "await_deploy_credit: max_wait must be > 0".into(),
148            ));
149        }
150        let deadline = Instant::now() + max_wait;
151        let mut delay = Duration::from_millis(500);
152        let cap = Duration::from_secs(5);
153
154        loop {
155            let credits = self.check_deploy_credit(wallet.address()).await?;
156            if credits > 0 {
157                return Ok(());
158            }
159            let now = Instant::now();
160            if now >= deadline {
161                return Err(ClientError::Validation(format!(
162                    "await_deploy_credit: timed out after {:?} without seeing credit",
163                    max_wait
164                )));
165            }
166            let remaining = deadline - now;
167            let sleep_for = delay.min(remaining);
168            sleep(sleep_for).await;
169            delay = (delay * 2).min(cap);
170        }
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    #[test]
179    fn auction_kind_serializes_snake_case() {
180        assert_eq!(
181            serde_json::to_string(&AuctionKind::TokenRegister).unwrap(),
182            "\"token_register\""
183        );
184        assert_eq!(
185            serde_json::to_string(&AuctionKind::PerpDeploy).unwrap(),
186            "\"perp_deploy\""
187        );
188        assert_eq!(
189            serde_json::to_string(&AuctionKind::SpotPairDeploy).unwrap(),
190            "\"spot_pair_deploy\""
191        );
192    }
193
194    #[test]
195    fn auction_bid_round_trips() {
196        let b = AuctionBid {
197            kind: AuctionKind::PerpDeploy,
198            bid_amount_usdc_cents: 150_000_000, // $1.5M in cents
199        };
200        let j = serde_json::to_string(&b).unwrap();
201        let dec: AuctionBid = serde_json::from_str(&j).unwrap();
202        assert_eq!(b, dec);
203    }
204
205    #[test]
206    fn bid_receipt_round_trips() {
207        let r = BidReceipt {
208            round_id: 42,
209            bidder: Address::ZERO,
210            accepted_amount_usdc_cents: 100_000_000, // $1.0M in cents
211            status: "accepted".into(),
212        };
213        let j = serde_json::to_string(&r).unwrap();
214        let dec: BidReceipt = serde_json::from_str(&j).unwrap();
215        assert_eq!(r, dec);
216    }
217
218    #[tokio::test]
219    async fn await_deploy_credit_rejects_zero_wait() {
220        let c = Client::new("https://devnet-gateway.mtf.exchange").unwrap();
221        let w = Wallet::random_for_testing();
222        let err = c.await_deploy_credit(&w, Duration::ZERO).await.unwrap_err();
223        assert!(matches!(err, ClientError::Validation(_)));
224    }
225}