Skip to content

Commit 9444d38

Browse files
committed
feat: allow importing stitch.toml in bot setup wizard
- Add `toml` field to `CreateRequest` to accept a full config file as an alternative to custom corridor fields - Validate imported TOML through `validate_imported_toml()` to reject files containing secret keys, `[signer]` sections, or other security risks before writing - Add `import_toml` module with comprehensive secret-key detection (exact matches and substring patterns) and file-size limits (64 KiB) - Reject requests that provide both `custom` corridor fields and `toml` simultaneously - Update web UI to toggle between "Enter the fields" and "Import stitch.toml" modes in the custom corridor step - Add file upload and textarea input for pasting or uploading `stitch.toml` in the UI - Extract `chain_id` from imported TOML to populate the chain picker automatically - Bump version to 0.1.229
1 parent 1973e40 commit 9444d38

8 files changed

Lines changed: 479 additions & 8 deletions

File tree

.textile-monorepo-source

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
9973cf47c734763cf7adecea909e900b04ee45df
1+
f2b8d64a3590275fcfd4c7e96fac2b6147c6741a

.textile-stitch-release-version

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.1.228
1+
0.1.229

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "stitch-bot"
3-
version = "0.1.228"
3+
version = "0.1.229"
44
edition = "2021"
55
description = "Stitch — Textile filler-network operator bot; quotes Swap via RFQ firm quotes and optionally fills resting limit orders."
66
license = "AGPL-3.0-or-later"

src/panel/http/wizard.rs

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,11 @@ pub struct CreateRequest {
262262
/// When present it takes precedence over `corridor_id`.
263263
#[serde(default)]
264264
pub custom: Option<CustomCorridor>,
265+
/// A full `stitch.toml` to write as-is (admin-generated or hand-edited).
266+
/// Mutually exclusive with `custom`. Validated through the same parser the
267+
/// bot uses; `[signer]` and other secret keys are refused.
268+
#[serde(default)]
269+
pub toml: Option<String>,
265270
pub signer: SignerRequest,
266271
/// Start the bot immediately. Off by default: the recommended path is to
267272
/// approve Permit2 (costs a little gas) and dry-run first.
@@ -290,6 +295,30 @@ impl CreateRequest {
290295
/// pending preset, or invalid custom details) is refused before anything is
291296
/// written.
292297
fn resolve_corridor(&self) -> Result<ResolvedCorridor, ApiError> {
298+
if self.custom.is_some() && self.toml.as_ref().is_some_and(|s| !s.trim().is_empty()) {
299+
return Err(ApiError::bad_request(
300+
"send either custom corridor fields or a stitch.toml, not both",
301+
));
302+
}
303+
304+
if let Some(raw) = self
305+
.toml
306+
.as_deref()
307+
.map(str::trim)
308+
.filter(|s| !s.is_empty())
309+
{
310+
let toml = setup::validate_imported_toml(raw)
311+
.map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
312+
let cfg = crate::config::Config::from_toml(&toml)
313+
.map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
314+
return Ok(ResolvedCorridor {
315+
toml,
316+
container_label: None,
317+
display_name: "a custom corridor".to_string(),
318+
network_label: format!("chain {}", cfg.chain_id),
319+
});
320+
}
321+
293322
if let Some(custom) = &self.custom {
294323
let toml = custom
295324
.render()
@@ -1155,6 +1184,84 @@ mod tests {
11551184
);
11561185
}
11571186

1187+
fn imported_toml() -> String {
1188+
r#"
1189+
chain_id = 42220
1190+
rpc_url = "https://forno.celo.org"
1191+
indexer_url = "https://api.textilecredit.com"
1192+
permit2 = "0x000000000022D473030F116dDEE9F6B43aC78BA3"
1193+
reactor = "0xa9AA0a64769cBed4d3B1Ceb4Df01CdE915C235b3"
1194+
tick_interval_secs = 5
1195+
1196+
[feed]
1197+
url = "https://api.textilecredit.com/price?chainId=42220&pair=cngn-usdt"
1198+
staleness_secs = 900
1199+
1200+
[[pools]]
1201+
collateral = "0x1111111111111111111111111111111111111111"
1202+
collateral_decimals = 6
1203+
debt = "0x2222222222222222222222222222222222222222"
1204+
debt_decimals = 6
1205+
buy_offset_bps = 5
1206+
sell_offset_bps = 5
1207+
ttl_secs = 60
1208+
"#
1209+
.to_string()
1210+
}
1211+
1212+
#[tokio::test]
1213+
async fn an_imported_toml_writes_the_file_verbatim() {
1214+
let h = harness("create-import-toml");
1215+
let toml_body = imported_toml();
1216+
let (status, body) = h
1217+
.post_json(
1218+
"/api/bots",
1219+
json!({ "name": "bot-a", "toml": toml_body, "signer": local(TEST_KEY) }),
1220+
)
1221+
.await;
1222+
assert_eq!(status, StatusCode::CREATED, "{body}");
1223+
let written = std::fs::read_to_string(h.root.join("bot-a/stitch.toml")).unwrap();
1224+
assert!(
1225+
written.contains("0x1111111111111111111111111111111111111111"),
1226+
"{written}"
1227+
);
1228+
assert!(crate::config::Config::from_toml(&written).is_ok());
1229+
}
1230+
1231+
#[tokio::test]
1232+
async fn an_imported_toml_with_a_signer_is_refused() {
1233+
let h = harness("create-import-signer");
1234+
let toml_body =
1235+
imported_toml() + "\n[signer]\nbackend = \"local\"\nprivate_key = \"0xabc\"\n";
1236+
let (status, body) = h
1237+
.post_json(
1238+
"/api/bots",
1239+
json!({ "name": "bot-a", "toml": toml_body, "signer": local(TEST_KEY) }),
1240+
)
1241+
.await;
1242+
assert_eq!(status, StatusCode::BAD_REQUEST, "{body}");
1243+
assert!(body.contains("signer"), "{body}");
1244+
assert!(!h.root.join("bot-a").exists());
1245+
}
1246+
1247+
#[tokio::test]
1248+
async fn custom_fields_and_toml_together_are_refused() {
1249+
let h = harness("create-import-both");
1250+
let (status, body) = h
1251+
.post_json(
1252+
"/api/bots",
1253+
json!({
1254+
"name": "bot-a",
1255+
"custom": custom_body(),
1256+
"toml": imported_toml(),
1257+
"signer": local(TEST_KEY)
1258+
}),
1259+
)
1260+
.await;
1261+
assert_eq!(status, StatusCode::BAD_REQUEST, "{body}");
1262+
assert!(body.contains("not both"), "{body}");
1263+
}
1264+
11581265
#[tokio::test]
11591266
async fn a_custom_corridor_with_a_garbage_token_is_refused() {
11601267
let h = harness("create-custom-bad-token");

src/setup/import_toml.rs

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
// Copyright (c) 2026 Textile, Inc.
3+
//! Validate an operator-supplied `stitch.toml` before the panel writes it.
4+
//!
5+
//! The admin corridor flow generates a file; the panel's custom-corridor step
6+
//! can import it instead of filling fields. This is the gate: parse, load
7+
//! through [`Config::from_toml`], and refuse anything that looks like a
8+
//! secret. The wizard collects the wallet separately — a toml that already
9+
//! carries a `[signer]` (or a private key field) is how keys leak.
10+
11+
use anyhow::{bail, Context, Result};
12+
13+
use crate::config::Config;
14+
15+
/// Hard cap so a pasted bomb can't sit in memory. A real stitch.toml is a few
16+
/// kilobytes; 64 KiB is generous for comments and still small.
17+
pub const MAX_TOML_BYTES: usize = 64 * 1024;
18+
19+
/// Exact key names that mean a secret lives in this file.
20+
const FORBIDDEN_KEYS: &[&str] = &[
21+
"signer",
22+
"private_key",
23+
"seed_phrase",
24+
"mnemonic",
25+
"api_private_key",
26+
"api_token",
27+
"api_key",
28+
"secret_key",
29+
"password",
30+
"passwd",
31+
"access_token",
32+
"client_secret",
33+
"client_key",
34+
"auth_token",
35+
"credential",
36+
"credentials",
37+
"token",
38+
"secret",
39+
];
40+
41+
/// Substrings that make a key secret-bearing even when the exact name is new.
42+
const FORBIDDEN_KEY_PARTS: &[&str] = &[
43+
"password",
44+
"passwd",
45+
"secret",
46+
"private_key",
47+
"mnemonic",
48+
"seed_phrase",
49+
"access_token",
50+
"client_secret",
51+
"credential",
52+
];
53+
54+
fn looks_like_secret_key(key: &str) -> bool {
55+
let k = key.to_ascii_lowercase().replace('-', "_");
56+
if FORBIDDEN_KEYS.iter().any(|f| k == *f) {
57+
return true;
58+
}
59+
if FORBIDDEN_KEY_PARTS.iter().any(|part| k.contains(part)) {
60+
return true;
61+
}
62+
k.ends_with("_token") || k.ends_with("_secret") || k.ends_with("_password")
63+
}
64+
65+
/// Validate a pasted / uploaded `stitch.toml`. On success returns the original
66+
/// body (comments preserved) after proving the bot can load it.
67+
pub fn validate_imported_toml(raw: &str) -> Result<String> {
68+
if raw.len() > MAX_TOML_BYTES {
69+
bail!(
70+
"stitch.toml is too large ({} bytes; max is {} KiB)",
71+
raw.len(),
72+
MAX_TOML_BYTES / 1024
73+
);
74+
}
75+
let trimmed = raw.trim();
76+
if trimmed.is_empty() {
77+
bail!("paste a stitch.toml, or pick a file");
78+
}
79+
80+
let parsed: toml::Value = toml::from_str(trimmed).context("this is not valid TOML")?;
81+
let mut keys = Vec::new();
82+
collect_keys(&parsed, &mut keys);
83+
if let Some(key) = keys.iter().find(|k| looks_like_secret_key(k)) {
84+
bail!(
85+
"this file includes a `{key}` field. Remove it — the wizard collects \
86+
the wallet separately, and importing a key in a toml file is how keys leak."
87+
);
88+
}
89+
90+
let cfg =
91+
Config::from_toml(trimmed).context("this stitch.toml is not a config the bot can load")?;
92+
if cfg.signer.is_some() {
93+
bail!(
94+
"this file includes a [signer] section. Remove it — the wizard \
95+
collects the wallet separately."
96+
);
97+
}
98+
if cfg.pools.is_empty() {
99+
bail!("this stitch.toml has no [[pools]] — a bot needs at least one pair");
100+
}
101+
102+
Ok(trimmed.to_string())
103+
}
104+
105+
fn collect_keys(value: &toml::Value, out: &mut Vec<String>) {
106+
match value {
107+
toml::Value::Table(table) => {
108+
for (key, child) in table {
109+
out.push(key.clone());
110+
collect_keys(child, out);
111+
}
112+
}
113+
toml::Value::Array(items) => {
114+
for child in items {
115+
collect_keys(child, out);
116+
}
117+
}
118+
_ => {}
119+
}
120+
}
121+
122+
#[cfg(test)]
123+
mod tests {
124+
use super::*;
125+
126+
fn valid() -> String {
127+
r#"
128+
chain_id = 42220
129+
rpc_url = "https://forno.celo.org"
130+
indexer_url = "https://api.textilecredit.com"
131+
permit2 = "0x000000000022D473030F116dDEE9F6B43aC78BA3"
132+
reactor = "0xa9AA0a64769cBed4d3B1Ceb4Df01CdE915C235b3"
133+
tick_interval_secs = 5
134+
135+
[feed]
136+
url = "https://api.textilecredit.com/price?chainId=42220&pair=cngn-usdt"
137+
staleness_secs = 900
138+
139+
[[pools]]
140+
collateral = "0x1111111111111111111111111111111111111111"
141+
collateral_decimals = 6
142+
debt = "0x2222222222222222222222222222222222222222"
143+
debt_decimals = 6
144+
buy_offset_bps = 5
145+
sell_offset_bps = 5
146+
ttl_secs = 60
147+
"#
148+
.to_string()
149+
}
150+
151+
#[test]
152+
fn a_valid_file_is_accepted_and_loads() {
153+
let out = validate_imported_toml(&valid()).expect("valid toml");
154+
Config::from_toml(&out).expect("bot loads it");
155+
}
156+
157+
#[test]
158+
fn a_signer_table_is_refused() {
159+
let err = validate_imported_toml(
160+
&(valid() + "\n[signer]\nbackend = \"local\"\nprivate_key = \"0xabc\"\n"),
161+
)
162+
.unwrap_err();
163+
assert!(err.to_string().contains("signer"), "{err}");
164+
}
165+
166+
#[test]
167+
fn secret_like_keys_are_refused_even_when_not_on_the_short_list() {
168+
for extra in [
169+
"\npassword = \"hunter2\"\n",
170+
"\naccess_token = \"tok\"\n",
171+
"\nclient_secret = \"shh\"\n",
172+
"\npanel_password = \"x\"\n",
173+
] {
174+
let err = validate_imported_toml(&(valid() + extra)).unwrap_err();
175+
assert!(
176+
err.to_string().contains("field"),
177+
"expected a secret-field error for {extra:?}, got {err}"
178+
);
179+
}
180+
}
181+
182+
#[test]
183+
fn api_key_env_is_not_treated_as_a_secret() {
184+
validate_imported_toml(&(valid() + "\napi_key_env = \"STITCH_MAKER_API_KEY\"\n"))
185+
.expect("env var name is not a secret");
186+
}
187+
188+
#[test]
189+
fn a_private_key_field_is_refused_even_outside_signer() {
190+
// A comment mentioning the words is fine; a parsed key is not.
191+
let with_comment = format!(
192+
"# the wallet key is NOT here; no private_key in this file\n{}",
193+
valid()
194+
);
195+
validate_imported_toml(&with_comment).expect("comment is not a key");
196+
}
197+
198+
#[test]
199+
fn empty_and_oversized_files_are_refused() {
200+
assert!(validate_imported_toml(" ").is_err());
201+
let huge = "x".repeat(MAX_TOML_BYTES + 1);
202+
assert!(validate_imported_toml(&huge)
203+
.unwrap_err()
204+
.to_string()
205+
.contains("too large"));
206+
}
207+
208+
#[test]
209+
fn garbage_toml_is_refused_before_the_bot_parser() {
210+
let err = validate_imported_toml("this is not toml [[[").unwrap_err();
211+
assert!(err.to_string().contains("not valid TOML"), "{err}");
212+
}
213+
214+
#[test]
215+
fn a_config_the_bot_cannot_load_is_refused() {
216+
let err = validate_imported_toml(
217+
r#"
218+
chain_id = 42220
219+
rpc_url = "https://forno.celo.org"
220+
permit2 = "0x000000000022D473030F116dDEE9F6B43aC78BA3"
221+
reactor = "0xa9AA0a64769cBed4d3B1Ceb4Df01CdE915C235b3"
222+
[feed]
223+
url = "ftp://evil.example/price"
224+
[[pools]]
225+
collateral = "0x1111111111111111111111111111111111111111"
226+
collateral_decimals = 6
227+
debt = "0x2222222222222222222222222222222222222222"
228+
debt_decimals = 6
229+
"#,
230+
)
231+
.unwrap_err();
232+
assert!(
233+
err.to_string().contains("not a config the bot can load"),
234+
"{err}"
235+
);
236+
}
237+
}

0 commit comments

Comments
 (0)