1mod 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}