1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
use clap::Parser;
use fuels::{
accounts::{
provider::{
Backoff,
Provider,
RetryConfig,
},
signers::private_key::PrivateKeySigner,
wallet::Wallet,
},
crypto::SecretKey,
};
use o2_deploy::{
DeployParams,
MarketsConfigPartial,
load_config_from_file,
};
use std::{
str::FromStr,
time::Duration,
};
/// CLI for deploying O2 exchange contracts to Fuel.
#[derive(Parser)]
#[command(name = "o2-deploy", about = "Deploy O2 exchange contracts")]
struct Cli {
/// Hex-encoded private key for signing deploy transactions.
#[arg(long, env = "DEPLOY_KEY")]
deploy_key: String,
/// Fuel RPC URL for sending transactions.
#[arg(
long = "fuel-rpc",
env = "FUEL_RPC",
default_value = "http://127.0.0.1:4000"
)]
fuel_rpc: url::Url,
/// How many times to attempt each RPC call before giving up.
///
/// `fuels` defaults this to 1 — NO retries — so one transport hiccup
/// anywhere in a deploy aborts the whole run, leaving the estate
/// half-upgraded and the config write-back unwritten. A deploy makes
/// hundreds of calls, so at that default the question is not whether
/// a run trips but when.
///
/// The endpoint is normally a load balancer over several nodes that
/// are NOT height-consistent, while the SDK stamps every post-submit
/// request with `required_fuel_block_height` so it cannot be served
/// state older than what it just wrote. A request routed to a replica
/// that has not caught up fails with "The required block height was
/// not met" — the error that aborted three consecutive mainnet
/// deploys on 2026-08-11, when two sentries stalled ~170 blocks
/// behind the tip.
///
/// A retry helps two ways: the backoff gives the replica time to
/// catch up, and a fresh connection may be balanced onto a different
/// one. Neither is guaranteed — connection reuse can pin a retry to
/// the same node — which is why this is a mitigation and not a
/// substitute for pointing a deploy at a single node.
///
/// Set to 1 to restore the old fail-fast behaviour.
#[arg(long, env = "FUEL_RPC_RETRIES", default_value = "5")]
rpc_retries: u32,
/// Path to the deploy config JSON file.
#[arg(long, env = "DEPLOY_CONFIG", default_value = "./deploy_config.json")]
deploy_config: String,
/// Output file path for the deploy result JSON.
#[arg(long, env = "OUTPUT_FILE")]
output: Option<String>,
/// Deploy a whitelist contract.
#[arg(long, env = "DEPLOY_WHITELIST", default_value = "false")]
deploy_whitelist: bool,
/// Deploy a blacklist contract.
#[arg(long, env = "DEPLOY_BLACKLIST", default_value = "true")]
deploy_blacklist: bool,
/// If set, will attempt to upgrade bytecode of deployed contracts.
#[arg(long, env, default_value = "false")]
upgrade_bytecode: bool,
/// Transfer proxy ownership to this address after deploy/upgrade.
#[arg(long, env = "DEPLOY_NEW_PROXY_OWNER")]
new_proxy_owner: Option<String>,
/// Transfer non-proxy contract ownership to this address after deploy/upgrade.
#[arg(long, env = "DEPLOY_NEW_CONTRACT_OWNER")]
new_contract_owner: Option<String>,
/// Cosigner address for trial trade accounts. When set, the trial trade
/// account implementation is (re)deployed on the trial oracle and this
/// cosigner is configured on it; when absent, the configured cosigner is
/// left untouched.
#[arg(long, env = "DEPLOY_TRIAL_COSIGNER")]
trial_cosigner: Option<String>,
/// Reconcile the margin TIERS only, leaving the margin system — pool,
/// oracle, price feed and the registry's prop wiring — untouched.
///
/// The mode used to be inferred from `margin.margin_pool_id` being
/// present in the markets config, which conflated WHICH pool with
/// WHETHER to touch the system: the ordinary steady state, a pool id
/// on file, silently disabled the system phase — so a registry
/// upgrade that dropped the prop wiring was never repaired.
#[arg(long, env = "DEPLOY_MARGIN_TIER_ONLY")]
margin_tier_only: bool,
/// Cosigner address (`0x…`) for prop/margin accounts. When set, it is
/// written to the prop account oracle if it differs from the live one;
/// when absent, the configured cosigner is left untouched. Must be the
/// address whose key the backend runs as `MARGIN_COSIGNER_KEY` - a
/// mismatch leaves margin inert.
#[arg(long, env = "DEPLOY_MARGIN_COSIGNER")]
margin_cosigner: Option<String>,
/// Recipient of the residue from forced margin exits, prefixed with its
/// kind: `address:0x…` or `contract:0x…`. When set, it is written to the
/// pool if it differs from the live one; when absent, the live value is
/// left untouched.
#[arg(long, env = "DEPLOY_MARGIN_LIQUIDATOR")]
margin_liquidator: Option<String>,
/// Identity allowed to register (activate) trial trade accounts on the
/// registry, prefixed with its kind: `address:0x…` for a wallet that
/// signs the registration transaction itself, or `contract:0x…` for a
/// trade account contract the backend routes the call through. When set,
/// it is written via set_trial_trade_account_creator if it differs; when
/// absent, the creator is left untouched.
#[arg(long, env = "DEPLOY_TRIAL_CREATOR")]
trial_creator: Option<String>,
/// Revoke the order-book maintainer role from these addresses before granting
/// the new maintainer. Repeatable, or comma-separated via the env var.
#[arg(
long,
env = "DEPLOY_REVOKE_ORDERBOOK_MAINTAINERS",
value_delimiter = ','
)]
revoke_orderbook_maintainer: Vec<String>,
/// Grant the order-book maintainer role to these addresses after deploy/upgrade.
/// Repeatable, or comma-separated via the env var.
#[arg(long, env = "DEPLOY_NEW_ORDERBOOK_MAINTAINERS", value_delimiter = ',')]
new_orderbook_maintainer: Vec<String>,
}
fn parse_address(s: &str) -> anyhow::Result<fuels::types::Address> {
let trimmed = s.strip_prefix("0x").unwrap_or(s);
let bytes = hex::decode(trimmed)?;
Ok(fuels::types::Address::new(
bytes
.try_into()
.map_err(|_| anyhow::anyhow!("Invalid address length"))?,
))
}
/// The registry only compares `msg_sender()` against the stored creator, so
/// an `Address` creator can never register through a trade account contract
/// and vice versa — the caller has to say which shape they mean.
fn parse_identity(s: &str) -> anyhow::Result<fuels::types::Identity> {
if let Some(address) = s.strip_prefix("address:") {
Ok(fuels::types::Identity::Address(parse_address(address)?))
} else if let Some(contract_id) = s.strip_prefix("contract:") {
Ok(fuels::types::Identity::ContractId(
fuels::types::ContractId::new(*parse_address(contract_id)?),
))
} else {
anyhow::bail!(
"identity must specify its kind: `address:0x…` for a wallet that \
signs directly, or `contract:0x…` for a trade account contract \
the call is routed through"
)
}
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.init();
let cli = Cli::parse();
let secret_key = SecretKey::from_str(&cli.deploy_key)
.map_err(|e| anyhow::anyhow!("Failed to parse deploy key: {e}"))?;
// Exponential from 250ms: waits of 0.25s, 0.5s, 1s and 2s between the
// five attempts, so a call spends at most ~3.75s retrying before it
// gives up. Cheap on a one-off blip, and short enough that a genuinely
// broken endpoint still fails the run in minutes rather than hours —
// raise --rpc-retries for a known-lagging node instead of making the
// default slow for everyone.
//
// The retry sits in the SDK's own `RetryableClient`, which wraps every
// provider call and retries on any error, so this one line covers the
// whole deploy — blob uploads, contract calls and read-only
// simulations alike — rather than each call site growing its own loop.
// Contract reverts arrive as a successful response carrying a failure
// status, not as an error, so they still fail immediately.
let retry_config = RetryConfig::new(
cli.rpc_retries,
Backoff::Exponential(Duration::from_millis(250)),
)
.map_err(|e| anyhow::anyhow!("invalid --rpc-retries: {e}"))?;
let provider = Provider::connect(cli.fuel_rpc.as_str())
.await?
.with_retry_config(retry_config);
let wallet = Wallet::new(PrivateKeySigner::new(secret_key), provider);
let deploy_config: MarketsConfigPartial = load_config_from_file(&cli.deploy_config)?;
let new_proxy_owner = cli
.new_proxy_owner
.as_deref()
.map(parse_address)
.transpose()?;
let new_contract_owner = cli
.new_contract_owner
.as_deref()
.map(parse_address)
.transpose()?;
let trial_cosigner = cli
.trial_cosigner
.as_deref()
.map(parse_address)
.transpose()?;
let trial_creator = cli
.trial_creator
.as_deref()
.map(parse_identity)
.transpose()?;
let margin_cosigner = cli
.margin_cosigner
.as_deref()
.map(parse_address)
.transpose()?;
let margin_liquidator = cli
.margin_liquidator
.as_deref()
.map(parse_identity)
.transpose()?;
let new_orderbook_maintainers = cli
.new_orderbook_maintainer
.iter()
.map(|s| parse_address(s))
.collect::<anyhow::Result<Vec<_>>>()?;
let revoke_orderbook_maintainers = cli
.revoke_orderbook_maintainer
.iter()
.map(|s| parse_address(s))
.collect::<anyhow::Result<Vec<_>>>()?;
let params = DeployParams {
deploy_config,
output: cli.output,
deploy_whitelist: cli.deploy_whitelist,
deploy_blacklist: cli.deploy_blacklist,
upgrade_bytecode: cli.upgrade_bytecode,
new_proxy_owner,
new_contract_owner,
trial_cosigner,
trial_creator,
margin_tier_only: cli.margin_tier_only,
margin_cosigner,
margin_liquidator,
revoke_orderbook_maintainers,
new_orderbook_maintainers,
};
let result = o2_deploy::deploy(wallet, params).await?;
println!("{}", serde_json::to_string_pretty(&result)?);
Ok(())
}
#[cfg(test)]
mod tests {
use super::parse_identity;
use fuels::types::Identity;
const HEX: &str = "6468f728c3c42d7805a998d7be4be93a382b3d66049e736ceb78ab4645b8a0f4";
#[test]
fn parses_address_identity() {
let identity = parse_identity(&format!("address:0x{HEX}")).unwrap();
assert!(matches!(identity, Identity::Address(_)));
}
#[test]
fn parses_contract_identity() {
let identity = parse_identity(&format!("contract:0x{HEX}")).unwrap();
assert!(matches!(identity, Identity::ContractId(_)));
}
#[test]
fn rejects_bare_value_without_kind() {
let err = parse_identity(&format!("0x{HEX}")).unwrap_err();
assert!(err.to_string().contains("must specify its kind"));
}
}