Skip to content

Commit 99eb3c9

Browse files
committed
feat(panel): require email verification for access requests
- Make email required (not optional) for access requests, as Textile needs a confirmed contact to reply on - Add email_verified field to track whether operator has clicked the confirm link sent by Textile - Display verification hint in pending requests showing which email the confirm link was sent to - Distinguish between "flagged" (blocked permanently) and "rejected" (can request again) access statuses - Update messaging to clarify that one request covers every Swap pair on every chain, including pairs listed later - Improve documentation on the email confirmation step and approval scope - Add support for 4007 (VENUE_SEATS_CHANGED_CODE) close frame as a handover redirect requiring reconnect - Update web UI copy to reflect email requirement and approval covering all pairs at once
1 parent 129e52e commit 99eb3c9

10 files changed

Lines changed: 128 additions & 37 deletions

File tree

.textile-monorepo-source

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
f66d053934c70322ab67fb074f5c00495b73bda1
1+
79c01ad231c310d2c468009fdbc5faa5bb1684db

.textile-stitch-release-version

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.1.234
1+
0.1.235

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.234"
3+
version = "0.1.235"
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"

docs/migrate-book-to-rfq.md

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,15 @@ every new maker starts registered and dark:
2626

2727
1. **Connect.** The bot signs `MakerEnroll` with its funding wallet and gets a
2828
maker id and key back. No corridor is enabled yet.
29-
2. **Request access.** Tell Textile who you are — email or WhatsApp. That sends
30-
ops a dossier (wallet, chain, maker id, corridor, contact) with Approve and
31-
Reject.
32-
3. **They approve**, which unflags the maker and seats every RFQ-capable
33-
corridor on that chain.
29+
2. **Request access.** Tell Textile who you are. An email address you own is
30+
required (placeholder and throwaway domains are refused); WhatsApp is
31+
optional. That sends ops a dossier (wallet, chain, maker id, corridor,
32+
contact) with Approve and Reject, and sends *you* a link to confirm the
33+
address. Click it — the request is reviewed either way, but it shows up as
34+
unverified until you do. You ask once: the same request covers every bot
35+
you run on this wallet.
36+
3. **They approve**, which seats the maker on every RFQ corridor on every
37+
chain — including pairs Textile lists later. You get an email saying so.
3438
4. **Check status.** Your bot picks the seat up and goes live. This does not
3539
rotate your key.
3640

@@ -47,10 +51,10 @@ book bot is never dark in the gap. That is the whole reason to leave the ladder
4751
with its own funding wallet; Textile registers the maker and the panel writes
4852
`rfq-api.key` beside the config. You never paste an id or key.
4953
3. Ask Textile to seat the maker. Newer panels have a **Request access** form
50-
in the RFQ card — an email address or a WhatsApp number — and the card then
51-
reads "Access requested". If your panel has no such form, mail
52-
`contact@textilecredit.com` with the maker id from `[rfq].maker_id`, the
53-
chain, and the pair.
54+
in the RFQ card — an email address you own, WhatsApp optional — and the
55+
card then reads "Access requested". Confirm the address from the link they
56+
email you. If your panel has no such form, mail `contact@textilecredit.com`
57+
with the maker id from `[rfq].maker_id`, the chain, and the pair.
5458
4. Wait for approval. When it lands, press **Check status** if your panel has
5559
it — otherwise press **Reconnect**, which re-enrolls and picks the seat up
5660
(it rotates the maker key; Check status does not). Either way the card goes
@@ -215,8 +219,8 @@ defaults to true) and restart.
215219

216220
Either way, leave `[rfq]` in place. `enabled = false` parks the responder
217221
without discarding your maker id, so switching back is one edit rather than
218-
another enrollment — and it keeps your seat, which you would otherwise have to
219-
be approved for again.
222+
another enrollment. Your approval is not lost either way: it is per maker, not
223+
per pair, so switching back never needs a second review.
220224

221225
Orders you already signed stay live on the book until they expire, whichever
222226
direction you move.

src/panel/http/access.rs

Lines changed: 86 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@
33
//! Request Textile to seat this maker, and poll until they do.
44
//!
55
//! Connect only registers a key. This is the last setup step: the panel
6-
//! posts contact details to the venue, which emails ops. Check status
6+
//! posts contact details to the venue, which emails ops. Asked once per
7+
//! maker — approval covers every RFQ corridor on every chain, including
8+
//! pairs listed later. The email is required and the venue mails a confirm
9+
//! link beside the review; the request is reviewed either way. Check status
710
//! applies corridors once approved, without rotating the key.
811
912
use axum::extract::{Path as UrlPath, State};
@@ -51,6 +54,37 @@ struct AccessStatusResponse {
5154
corridors: Vec<String>,
5255
#[serde(default)]
5356
corridor_pairs: Vec<EnrollCorridorPair>,
57+
#[serde(default)]
58+
contact_email: Option<String>,
59+
/// False until the operator clicks the confirm link. Older venues omit
60+
/// it, and "omitted" must not read as "not confirmed".
61+
#[serde(default)]
62+
email_verified: Option<bool>,
63+
}
64+
65+
/// What the venue says back to a filed request.
66+
#[derive(Debug, Deserialize)]
67+
#[serde(rename_all = "camelCase")]
68+
struct AccessRequestResponse {
69+
#[serde(default)]
70+
contact_email: Option<String>,
71+
#[serde(default)]
72+
email_verified: Option<bool>,
73+
}
74+
75+
/// The line under "Request sent" / "still pending": only nags about the
76+
/// confirm link while the venue actually says it is unconfirmed.
77+
fn verify_hint(contact_email: Option<&str>, email_verified: Option<bool>) -> String {
78+
match (email_verified, contact_email) {
79+
(Some(false), Some(email)) => format!(
80+
" Confirm your email: we sent a link to {email}. Request access again to resend it."
81+
),
82+
(Some(false), None) => {
83+
" Confirm your email: we sent you a link. Request access again to resend it."
84+
.to_string()
85+
}
86+
_ => String::new(),
87+
}
5488
}
5589

5690
/// Body for POST /v2/maker/access-request. The form leaves most of these
@@ -75,13 +109,13 @@ fn filled(value: Option<&str>) -> Option<&str> {
75109
}
76110

77111
/// Email is the channel Textile answers a review on, so it is the required
78-
/// one. WhatsApp is a bonus number for them to ping.
112+
/// one — and the one they ask you to confirm. WhatsApp is a bonus number.
79113
fn require_email(email: Option<&str>) -> Result<(), ApiError> {
80114
if filled(email).is_some() {
81115
return Ok(());
82116
}
83117
Err(ApiError::bad_request(
84-
"add an email address so Textile can reply to your access request — WhatsApp is optional",
118+
"add an email address you own so Textile can reply to your access request — WhatsApp is optional",
85119
))
86120
}
87121

@@ -151,10 +185,25 @@ pub async fn request_access(
151185
.unwrap_or_else(|| format!("Textile access request failed ({status})"));
152186
return Err(ApiError::bad_request(message));
153187
}
188+
let filed: AccessRequestResponse =
189+
serde_json::from_str(&text).unwrap_or(AccessRequestResponse {
190+
contact_email: None,
191+
email_verified: None,
192+
});
193+
let hint = verify_hint(
194+
filed
195+
.contact_email
196+
.as_deref()
197+
.or(filled(body.contact_email.as_deref())),
198+
filed.email_verified,
199+
);
154200

155201
Ok(Json(json!({
156-
"message": "Request sent. Textile will review it and email you if they need anything.",
202+
"message": format!(
203+
"Request sent. Textile reviews it and approves you for every Swap pair at once.{hint}"
204+
),
157205
"accessStatus": "PENDING",
206+
"emailVerified": filed.email_verified,
158207
}))
159208
.into_response())
160209
}
@@ -209,19 +258,28 @@ pub async fn access_status(
209258
})?;
210259

211260
if reported.access_status != "APPROVED" || reported.flagged {
212-
let message = if reported.flagged || reported.access_status == "REJECTED" {
261+
let message = if reported.flagged {
213262
format!(
214-
"Textile rejected {}. You will not receive private quotes.",
263+
"Textile blocked {}. You will not receive private quotes.",
264+
reported.maker_slug
265+
)
266+
} else if reported.access_status == "REJECTED" {
267+
format!(
268+
"Textile turned {} down. You can request access again.",
215269
reported.maker_slug
216270
)
217271
} else if reported.access_status == "PENDING" {
218-
"Textile still has your request. Nothing to do until they approve it.".to_string()
272+
format!(
273+
"Textile still has your request. Nothing to do until they approve it.{}",
274+
verify_hint(reported.contact_email.as_deref(), reported.email_verified)
275+
)
219276
} else {
220277
"No access request yet. Send one so Textile can review this maker.".to_string()
221278
};
222279
return Ok(Json(json!({
223280
"message": message,
224281
"accessStatus": reported.access_status,
282+
"emailVerified": reported.email_verified,
225283
"enrollment": {
226284
"makerSlug": reported.maker_slug,
227285
"environment": reported.environment,
@@ -358,10 +416,7 @@ mod tests {
358416
.and_then(|v| v.to_str().ok())
359417
.unwrap_or("");
360418
assert_eq!(auth, format!("Bearer {expect_key}"));
361-
assert!(
362-
body["contactEmail"].as_str().is_some()
363-
|| body["contactWhatsapp"].as_str().is_some()
364-
);
419+
assert!(body["contactEmail"].as_str().is_some());
365420
// The venue validates these as optional strings, so a
366421
// blank field must be absent rather than null.
367422
assert!(
@@ -372,7 +427,12 @@ mod tests {
372427
.any(Value::is_null),
373428
"sent a null field: {body}"
374429
);
375-
Json(json!({ "accessStatus": "PENDING", "requestId": "clreq1" }))
430+
Json(json!({
431+
"accessStatus": "PENDING",
432+
"requestId": "clreq1",
433+
"contactEmail": body["contactEmail"],
434+
"emailVerified": false,
435+
}))
376436
},
377437
),
378438
)
@@ -564,6 +624,20 @@ mod tests {
564624
assert!(!body.contains("tx_live_enroll_secret"), "{body}");
565625
let v = Harness::parse(&body);
566626
assert_eq!(v["accessStatus"], "PENDING");
627+
assert_eq!(v["emailVerified"], false);
628+
assert!(
629+
v["message"].as_str().unwrap().contains("desk@example.com"),
630+
"tells them where the confirm link went: {body}"
631+
);
632+
}
633+
634+
#[test]
635+
fn the_verify_hint_only_nags_while_unconfirmed() {
636+
assert!(verify_hint(Some("a@b.c"), Some(false)).contains("a@b.c"));
637+
assert_eq!(verify_hint(Some("a@b.c"), Some(true)), "");
638+
// An older venue that does not report it must not read as unconfirmed.
639+
assert_eq!(verify_hint(Some("a@b.c"), None), "");
640+
assert!(verify_hint(None, Some(false)).contains("Confirm your email"));
567641
}
568642

569643
#[tokio::test]

src/rfq/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1735,6 +1735,7 @@ mod tests {
17351735
fn only_the_redirect_codes_count_as_a_handover() {
17361736
assert!(session::handover_close(4005), "not-the-engine redirects");
17371737
assert!(session::handover_close(4006), "draining redirects");
1738+
assert!(session::handover_close(4007), "stale seats redirect");
17381739
// Everything in 4000-4004 is the maker's problem, not the task's:
17391740
// reconnecting instantly would hammer the venue with a credential it
17401741
// has already rejected.

src/rfq/session.rs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ const VENUE_SUPERSEDED_CODE: u16 = 4001;
3232
const VENUE_NOT_ENGINE_CODE: u16 = 4005;
3333
/// The api task we were on is shutting down and has released the lease.
3434
const VENUE_DRAINING_CODE: u16 = 4006;
35+
/// Our seat list went stale: a corridor was listed (or left Book mode) after we
36+
/// authenticated. Reconnecting is what picks it up — the venue hands the
37+
/// corridor pairs down in SessionAccepted and nothing refreshes them in place.
38+
const VENUE_SEATS_CHANGED_CODE: u16 = 4007;
3539

3640
/// The venue told us to move, and nothing is wrong with this maker.
3741
///
@@ -63,9 +67,13 @@ pub struct VenueSuperseded {
6367
pub reason: String,
6468
}
6569

66-
/// Does this close frame mean "wrong task" rather than "go away"?
70+
/// Does this close frame mean "reconnect now" rather than "go away"?
71+
///
72+
/// Two shapes, same policy: the task is wrong (4005/4006), or our seat list is
73+
/// (4007). In all three the socket and the credential are fine and the venue
74+
/// wants us straight back, so backing off is exactly wrong.
6775
pub fn handover_close(code: u16) -> bool {
68-
code == VENUE_NOT_ENGINE_CODE || code == VENUE_DRAINING_CODE
76+
code == VENUE_NOT_ENGINE_CODE || code == VENUE_DRAINING_CODE || code == VENUE_SEATS_CHANGED_CODE
6977
}
7078

7179
/// Was this failure another session taking our identity?

web/src/components/SettingsForm.tsx

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1131,9 +1131,10 @@ function RfqCard({
11311131
<p className="text-xs text-faint">
11321132
Connect registers this bot&apos;s funding wallet and saves the
11331133
credential. Textile still has to approve you before you receive
1134-
Swap quotes — Request access below, then Check status after they
1135-
do. You never paste an id or key. The venue rejects requests under 1
1136-
whole token so the protocol fee cannot round to zero.
1134+
Swap quotes — Request access below (once, for every pair), then
1135+
Check status after they do. You never paste an id or key. The venue
1136+
rejects requests under 1 whole token so the protocol fee cannot
1137+
round to zero.
11371138
</p>
11381139

11391140
{waiting ? (
@@ -1144,9 +1145,9 @@ function RfqCard({
11441145
: ''}
11451146
.{' '}
11461147
{makerFlagged
1147-
? 'Textile has flagged this maker. You will not receive Swap quotes.'
1148+
? 'Textile has blocked this maker. You will not receive Swap quotes.'
11481149
: rejected
1149-
? 'Textile turned this chain down. Request access again if you want another review.'
1150+
? 'Textile turned this maker down. Request access again if you want another review.'
11501151
: accessStatus === 'PENDING'
11511152
? 'Access requested. Textile will review it. Check status after they approve you.'
11521153
: 'Request access so Textile can review this maker. You will not receive Swap quotes until they approve you.'}
@@ -1188,9 +1189,10 @@ function RfqCard({
11881189
<div className="space-y-3 rounded-lg border border-line-soft p-3">
11891190
<p className="text-sm font-bold">Request access</p>
11901191
<p className="text-xs text-faint">
1191-
Textile replies about the review by email, so that one is
1192-
required. WhatsApp is optional — add it if you would rather they
1193-
ping you there.
1192+
One request covers every Swap pair, on every chain, including
1193+
pairs Textile lists later. They reply by email, so use an
1194+
address you own — they send a link to confirm it. WhatsApp is
1195+
optional.
11941196
</p>
11951197
<Field label="Email">
11961198
<Input

web/src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,8 @@ export interface SaveResult {
178178
export interface RfqAccessResult {
179179
message: string
180180
accessStatus: RfqAccessStatus
181+
/** False until the confirm link is clicked; absent from older venues. */
182+
emailVerified?: boolean | null
181183
settings?: Settings
182184
enrollment?: RfqEnrollment
183185
}

0 commit comments

Comments
 (0)