moss_bounty_x402_client/
lib.rs

1//! [![Crates.io](https://img.shields.io/crates/v/moss-bounty-x402-client)](https://crates.io/crates/moss-bounty-x402-client)
2//! [![docs](https://img.shields.io/crates/v/moss-bounty-x402-client?color=yellow&label=docs)](https://docs.rs/moss-bounty-x402-client)
3//!
4//! Moss Bounty X402 Client
5//!
6//! ## Installation
7//!
8//! ```toml
9//! [dependencies]
10//! moss-bounty-x402-client = "0.1.5"
11//! ```
12//!
13//! ## Basic Usage
14//!
15//! ```rust
16//! use moss_bounty_x402_client::{Client, CreateBountyTaskData};
17//!
18//! #[tokio::main]
19//! async fn main() -> anyhow::Result<()> {
20//!     // User moss authorization token, could be fetched by login
21//!     let auth_token = "<Your-Moss-Authorization-Token>";
22//!
23//!     // User wallet private key for bounty payment
24//!     let private = "<Your-Wallet-Private-Key>";
25//!
26//!     // Build a client
27//!     let client = Client::new(auth_token, private)?;
28//!
29//!     // Build task data
30//!     let task = CreateBountyTaskData {
31//!         target_twitter_handle: "<target_twitter_handle>".to_string(),
32//!         question: "Hello?".to_string(),
33//!         amount_usdc: "1000000".to_string(),
34//!         valid_hours: 12,
35//!     };
36//!
37//!     // Create bounty task
38//!     client.create_bounty_task(task).await?
39//!
40//!     Ok(())
41//! }
42//! ```
43
44mod eip3009;
45use alloy::primitives::{Address, B256, U256};
46use alloy::signers::local::PrivateKeySigner;
47use anyhow::Result;
48use base64::{Engine as _, engine::general_purpose};
49use rand::Rng;
50use serde::{Deserialize, Serialize};
51use std::str::FromStr;
52
53#[derive(Debug, Serialize, Deserialize)]
54pub struct CreateBountyTaskData {
55    pub target_twitter_handle: String,
56    pub question: String,
57    pub amount_usdc: String,
58    pub valid_hours: usize,
59}
60
61#[derive(Debug, Serialize, Deserialize)]
62#[serde(rename_all = "camelCase")]
63struct CreateBounty402Resp {
64    accepts: Vec<X402Accept>,
65    x402_version: usize,
66}
67
68#[derive(Debug, Serialize, Deserialize)]
69#[serde(rename_all = "camelCase")]
70struct X402Accept {
71    schema: String,
72    network: String,
73    max_amount_required: String,
74    pay_to: String,
75    max_time_seconds: i64,
76    asset: String,
77    extra: X402AcceptExtra,
78}
79
80#[derive(Debug, Serialize, Deserialize)]
81#[serde(rename_all = "camelCase")]
82struct X402AcceptExtra {
83    name: String,
84    version: String,
85}
86
87#[derive(Debug, Serialize, Deserialize)]
88#[serde(rename_all = "camelCase")]
89struct X402Payment {
90    x402_version: usize,
91    scheme: String,
92    network: String,
93    payload: X402PaymentPayload,
94}
95
96#[derive(Debug, Serialize, Deserialize)]
97#[serde(rename_all = "camelCase")]
98struct X402PaymentPayload {
99    signature: String,
100    authorization: X402Authorization,
101}
102
103#[derive(Debug, Serialize, Deserialize)]
104#[serde(rename_all = "camelCase")]
105struct X402Authorization {
106    mime_type: String,
107    from: String,
108    to: String,
109    value: String,
110    valid_after: String,
111    valid_before: String,
112    nonce: String,
113}
114
115const MOSS_API_HOST: &str = "https://ai.moss.site";
116
117pub struct Client {
118    auth_token: String,
119    host: String,
120    signer: PrivateKeySigner,
121}
122
123impl Client {
124    pub fn new(auth_token: &str, wallet_key: &str) -> Result<Self> {
125        Ok(Client {
126            host: MOSS_API_HOST.to_string(),
127            auth_token: auth_token.to_string(),
128            signer: wallet_key.parse::<PrivateKeySigner>()?,
129        })
130    }
131
132    pub async fn create_bounty_task(&self, data: CreateBountyTaskData) -> Result<()> {
133        const API_PATH: &str = "/api/v1/bounty/tasks";
134        let url = format!("{}/{}", self.host, API_PATH);
135
136        let rsp = reqwest::Client::default()
137            .post(&url)
138            .header("Authorization", format!("Bearer {}", self.auth_token))
139            .header("Content-Type", "application/json")
140            .json(&data)
141            .send()
142            .await?;
143
144        if rsp.status() != 402 {
145            return Err(anyhow::anyhow!("Except api status: {}", rsp.status()));
146        }
147
148        let rsp = rsp.json::<CreateBounty402Resp>().await?;
149
150        let payment = self.build_x402_payment(rsp)?;
151        let payment = general_purpose::STANDARD.encode(serde_json::to_vec(&payment)?.as_slice());
152
153        let rsp = reqwest::Client::default()
154            .post(&url)
155            .header("Authorization", format!("Bearer {}", self.auth_token))
156            .header("X-Payment", payment)
157            .header("Content-Type", "application/json")
158            .json(&data)
159            .send()
160            .await?;
161
162        if !rsp.status().is_success() {
163            return Err(anyhow::anyhow!("Except api status: {}", rsp.status()));
164        }
165
166        Ok(())
167    }
168
169    fn build_x402_payment(&self, rsp: CreateBounty402Resp) -> Result<X402Payment> {
170        if rsp.x402_version != 1 {
171            return Err(anyhow::anyhow!("Unsupported x402 version"));
172        }
173
174        if rsp.accepts.len() < 1 {
175            return Err(anyhow::anyhow!("Empty x402 accepts"));
176        }
177
178        let accept = &rsp.accepts[0];
179
180        if accept.schema != "exact" || accept.network != "base" {
181            return Err(anyhow::anyhow!(
182                "Unsupported accept schema or network: {}, {}",
183                accept.schema,
184                accept.network
185            ));
186        }
187
188        let domain = eip3009::Domain {
189            name: accept.extra.name.clone(),
190            version: accept.extra.version.clone(),
191            chain_id: eip3009::BASE_CHAIN_ID,
192            verifying_contract: Address::from_str(&accept.asset)?,
193        };
194
195        let message = eip3009::Message {
196            from: self.signer.address(),
197            to: accept.pay_to.parse::<Address>()?,
198            value: accept.max_amount_required.parse::<U256>()?,
199            valid_after: U256::from(0),
200            valid_before: U256::from(chrono::Utc::now().timestamp() + accept.max_time_seconds),
201            nonce: generate_nonce(),
202        };
203
204        let signature = eip3009::signing_hash(domain, &message)?;
205
206        let authorization = X402Authorization {
207            mime_type: "application/json".to_string(),
208            from: message.from.to_string(),
209            to: message.to.to_string(),
210            value: message.value.to_string(),
211            valid_after: message.valid_after.to_string(),
212            valid_before: message.valid_before.to_string(),
213            nonce: message.nonce.to_string(),
214        };
215
216        Ok(X402Payment {
217            x402_version: rsp.x402_version,
218            scheme: accept.schema.clone(),
219            network: accept.network.clone(),
220            payload: X402PaymentPayload {
221                signature: signature.to_string(),
222                authorization,
223            },
224        })
225    }
226}
227
228fn generate_nonce() -> B256 {
229    let mut nonce_bytes = [0u8; 32];
230    rand::thread_rng().fill(&mut nonce_bytes);
231    B256::from_slice(&nonce_bytes)
232}