LAWLESS1987 commited on
Commit
3f9464a
·
1 Parent(s): 7666751

Add files via upload

Browse files
AutomatedSetupModal.jsx ADDED
@@ -0,0 +1,242 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect, useRef, useMemo } from "react";
2
+ import { isValidLimit, computeMaxDailyExposure, computeExposurePctOfBalance } from "../lib/automatedLimits.js";
3
+
4
+ /**
5
+ * AutomatedSetupModal.jsx
6
+ *
7
+ * Replaces the prompt()/alert() placeholder that used to sit inline in
8
+ * Dashboard.jsx's handleTierChange. This is the one moment in the whole
9
+ * app where a human hands off standing permission to trade without a
10
+ * per-trade tap -- tradeGate.js already refuses to enable that tier
11
+ * without finite, positive limits (no "unlimited" option, checked on
12
+ * every signal, not just here); this form is the honest UI for setting
13
+ * them, not just a barrier to get past.
14
+ *
15
+ * SIGNATURE ELEMENT: the two numbers a person types here (max per trade,
16
+ * max trades per day) don't mean anything on their own -- their PRODUCT
17
+ * is the real question ("how much could this commit in a single day if
18
+ * every trade hit the cap"). That's computed live below the inputs, and
19
+ * related to the actual USD balance when known, rather than left as an
20
+ * exercise for the reader. Framed explicitly as a ceiling, not a loss
21
+ * estimate -- each trade is a grid buy, not a guaranteed loss, and
22
+ * overstating this as "money you will lose" would be its own kind of
23
+ * dishonesty this project has consistently tried to avoid elsewhere
24
+ * (see tradeGate.js and covenant_unified_v8.py's own HONESTY NOTEs).
25
+ *
26
+ * Validation lives in two places on purpose, not by accident: the fast
27
+ * inline check here (so the submit button reflects reality as you type,
28
+ * and you never even attempt a bad submission) AND tradeGate.js's own
29
+ * enableAutomated() (the actual authority -- see its own comment: "no
30
+ * unlimited option"). If those two ever disagreed, the gate wins; this
31
+ * form's job is to never let that disagreement surface as a raw
32
+ * exception, not to be a second source of truth.
33
+ */
34
+
35
+ const FIELD_STYLE = {
36
+ width: "100%", padding: "10px 12px", background: "#0d0f12",
37
+ border: "1px solid #2a2d33", borderRadius: 8, color: "#e8e6e1",
38
+ fontSize: 15, fontFamily: "ui-monospace, monospace",
39
+ };
40
+
41
+ function Field({ label, hint, value, onChange, inputRef, autoFocus, invalidMessage }) {
42
+ const [touched, setTouched] = useState(false);
43
+ const showError = touched && value !== "" && !isValidLimit(value);
44
+ return (
45
+ <div style={{ marginBottom: 16 }}>
46
+ <label style={{ fontSize: 12, color: "#8a8d93", letterSpacing: 0.5, display: "block", marginBottom: 6 }}>
47
+ {label}
48
+ </label>
49
+ <input
50
+ ref={inputRef}
51
+ autoFocus={autoFocus}
52
+ type="number"
53
+ inputMode="decimal"
54
+ min="0"
55
+ step="any"
56
+ placeholder="0"
57
+ value={value}
58
+ onChange={(e) => onChange(e.target.value)}
59
+ onBlur={() => setTouched(true)}
60
+ style={{
61
+ ...FIELD_STYLE,
62
+ borderColor: showError ? "#5a2626" : FIELD_STYLE.border,
63
+ }}
64
+ />
65
+ <div style={{ fontSize: 11, color: showError ? "#e08a8a" : "#5a5d63", marginTop: 5, minHeight: 14 }}>
66
+ {showError ? invalidMessage : hint}
67
+ </div>
68
+ </div>
69
+ );
70
+ }
71
+
72
+ export default function AutomatedSetupModal({ open, onCancel, onConfirm, usdBalance }) {
73
+ const [maxPerTradeUsd, setMaxPerTradeUsd] = useState("");
74
+ const [maxDailyTradesCount, setMaxDailyTradesCount] = useState("");
75
+ const [submitting, setSubmitting] = useState(false);
76
+ const [submitError, setSubmitError] = useState(null);
77
+ const firstFieldRef = useRef(null);
78
+
79
+ // Reset to a blank form every time the modal is (re)opened -- stale
80
+ // values from a cancelled attempt shouldn't reappear silently later.
81
+ useEffect(() => {
82
+ if (open) {
83
+ setMaxPerTradeUsd("");
84
+ setMaxDailyTradesCount("");
85
+ setSubmitError(null);
86
+ setSubmitting(false);
87
+ }
88
+ }, [open]);
89
+
90
+ useEffect(() => {
91
+ if (!open) return;
92
+ function onKeyDown(e) {
93
+ if (e.key === "Escape") onCancel();
94
+ }
95
+ document.addEventListener("keydown", onKeyDown);
96
+ return () => document.removeEventListener("keydown", onKeyDown);
97
+ }, [open, onCancel]);
98
+
99
+ const perTradeValid = isValidLimit(maxPerTradeUsd);
100
+ const dailyCountValid = isValidLimit(maxDailyTradesCount);
101
+ const bothValid = perTradeValid && dailyCountValid;
102
+
103
+ const maxDailyExposure = useMemo(
104
+ () => computeMaxDailyExposure(maxPerTradeUsd, maxDailyTradesCount),
105
+ [maxPerTradeUsd, maxDailyTradesCount]
106
+ );
107
+
108
+ const exposurePctOfBalance = useMemo(
109
+ () => computeExposurePctOfBalance(maxDailyExposure, usdBalance),
110
+ [maxDailyExposure, usdBalance]
111
+ );
112
+
113
+ if (!open) return null;
114
+
115
+ async function handleConfirm() {
116
+ if (!bothValid || submitting) return;
117
+ setSubmitting(true);
118
+ setSubmitError(null);
119
+ try {
120
+ await onConfirm({
121
+ maxPerTradeUsd: Number(maxPerTradeUsd),
122
+ maxDailyTradesCount: Number(maxDailyTradesCount),
123
+ });
124
+ // Success: the parent flips `open` to false via its own state once
125
+ // gate.enableAutomated() has actually taken effect, so this modal
126
+ // doesn't need (and shouldn't guess) its own success transition.
127
+ } catch (e) {
128
+ setSubmitError(e?.message || "Could not enable automated trading.");
129
+ setSubmitting(false);
130
+ }
131
+ }
132
+
133
+ return (
134
+ <div
135
+ role="presentation"
136
+ onMouseDown={(e) => { if (e.target === e.currentTarget) onCancel(); }}
137
+ style={{
138
+ position: "fixed", inset: 0, background: "rgba(5,6,7,0.72)",
139
+ display: "flex", alignItems: "flex-end", justifyContent: "center",
140
+ zIndex: 1000,
141
+ }}
142
+ >
143
+ <div
144
+ role="dialog"
145
+ aria-modal="true"
146
+ aria-labelledby="automated-setup-title"
147
+ style={{
148
+ width: "100%", maxWidth: 480, background: "#16181c",
149
+ borderTop: "1px solid #2a2d33", borderLeft: "1px solid #2a2d33", borderRight: "1px solid #2a2d33",
150
+ borderRadius: "18px 18px 0 0", padding: "10px 20px 24px",
151
+ boxShadow: "0 -8px 40px rgba(0,0,0,0.5)",
152
+ animation: "sheet-rise 180ms ease-out",
153
+ }}
154
+ >
155
+ <style>{`
156
+ @keyframes sheet-rise { from { transform: translateY(24px); opacity: 0.4; } to { transform: translateY(0); opacity: 1; } }
157
+ @media (prefers-reduced-motion: reduce) { * { animation-duration: 0.001ms !important; } }
158
+ `}</style>
159
+
160
+ <div style={{ width: 36, height: 4, borderRadius: 2, background: "#2a2d33", margin: "0 auto 16px" }} />
161
+
162
+ <h2 id="automated-setup-title" style={{ fontSize: 16, fontWeight: 500, margin: "0 0 4px", color: "#e8e6e1" }}>
163
+ Enable automated trading
164
+ </h2>
165
+ <p style={{ fontSize: 12.5, color: "#8a8d93", margin: "0 0 20px", lineHeight: 1.5 }}>
166
+ The gate runs the strategy on a timer within the limits below. A Ledger
167
+ confirmation is still required for every trade — this cannot be
168
+ switched off at any tier.
169
+ </p>
170
+
171
+ <Field
172
+ label="Max USD per trade"
173
+ hint="A finite amount, required — there is no unlimited option."
174
+ invalidMessage="Enter a number greater than 0."
175
+ value={maxPerTradeUsd}
176
+ onChange={setMaxPerTradeUsd}
177
+ inputRef={firstFieldRef}
178
+ autoFocus
179
+ />
180
+ <Field
181
+ label="Max trades per day"
182
+ hint="Resets at midnight UTC."
183
+ invalidMessage="Enter a number greater than 0."
184
+ value={maxDailyTradesCount}
185
+ onChange={setMaxDailyTradesCount}
186
+ />
187
+
188
+ <div style={{
189
+ background: "#0d0f12", border: "1px solid #21242a", borderRadius: 10,
190
+ padding: "12px 14px", marginBottom: 20,
191
+ }}>
192
+ <div style={{ fontSize: 11, color: "#8a8d93", letterSpacing: 0.5, marginBottom: 6 }}>
193
+ WORST-CASE DAILY DEPLOYMENT
194
+ </div>
195
+ <div style={{ fontFamily: "ui-monospace, monospace", fontSize: 20, color: bothValid ? "#c9a24b" : "#5a5d63" }}>
196
+ {bothValid ? `$${maxDailyExposure.toLocaleString(undefined, { maximumFractionDigits: 2 })}` : "—"}
197
+ </div>
198
+ <div style={{ fontSize: 11.5, color: "#5a5d63", marginTop: 4, lineHeight: 1.4 }}>
199
+ {bothValid
200
+ ? exposurePctOfBalance !== null
201
+ ? `If every trade hits the per-trade cap, ${exposurePctOfBalance.toFixed(0)}% of your current $${usdBalance.toFixed(2)} balance in one day.`
202
+ : "If every trade hits the per-trade cap. Not a loss estimate — each trade is a grid buy, not a guaranteed loss."
203
+ : "Fill in both fields to see the ceiling this sets."}
204
+ </div>
205
+ </div>
206
+
207
+ {submitError && (
208
+ <div style={{ background: "#2a1616", border: "1px solid #5a2626", borderRadius: 8, padding: 10, marginBottom: 16, fontSize: 12, color: "#e08a8a" }}>
209
+ {submitError}
210
+ </div>
211
+ )}
212
+
213
+ <div style={{ display: "flex", gap: 10 }}>
214
+ <button
215
+ onClick={onCancel}
216
+ disabled={submitting}
217
+ style={{
218
+ flex: 1, padding: 12, borderRadius: 8, border: "1px solid #2a2d33",
219
+ background: "transparent", color: "#a8abb1", fontSize: 14, fontWeight: 500,
220
+ }}
221
+ >
222
+ Cancel
223
+ </button>
224
+ <button
225
+ onClick={handleConfirm}
226
+ disabled={!bothValid || submitting}
227
+ style={{
228
+ flex: 2, padding: 12, borderRadius: 8, border: "1px solid #c9a24b",
229
+ background: bothValid && !submitting ? "#20242b" : "#16181c",
230
+ color: bothValid && !submitting ? "#c9a24b" : "#55585e",
231
+ fontSize: 14, fontWeight: 500,
232
+ cursor: bothValid && !submitting ? "pointer" : "default",
233
+ opacity: bothValid ? 1 : 0.6,
234
+ }}
235
+ >
236
+ {submitting ? "Enabling\u2026" : "Enable automated trading"}
237
+ </button>
238
+ </div>
239
+ </div>
240
+ </div>
241
+ );
242
+ }
Dashboard.jsx ADDED
@@ -0,0 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect, useCallback } from "react";
2
+ import { TIERS } from "../lib/tradeGate.js";
3
+ import { getTierRank, isTierClickable } from "../lib/tierNavigation.js";
4
+ import AutomatedSetupModal from "./AutomatedSetupModal.jsx";
5
+
6
+ /**
7
+ * Dashboard.jsx
8
+ *
9
+ * Design intent: this is a trading terminal, not a marketing page --
10
+ * dense, precise, calm. The tier ladder (dashboard -> manual ->
11
+ * automated) is rendered as a real structural element because it IS
12
+ * the real security model (see tradeGate.js), not decoration. Numbers
13
+ * that came from the walk-forward-validated baseline are visually
14
+ * distinguished from numbers the live adaptive layer just computed --
15
+ * conflating "this was proven out-of-sample" with "this is a live
16
+ * guess" in the UI would be the same category of silent-failure this
17
+ * whole project has consistently flagged as the dangerous thing to do.
18
+ */
19
+
20
+ const TIER_INFO = [
21
+ { key: TIERS.DASHBOARD, label: "Dashboard", desc: "Read-only. Balances and strategy status." },
22
+ { key: TIERS.MANUAL, label: "Manual approval", desc: "See signals, tap to confirm, sign on Ledger." },
23
+ { key: TIERS.AUTOMATED, label: "Automated", desc: "Runs within limits you set. Ledger confirmation still required per trade." },
24
+ ];
25
+
26
+ function TierLadder({ currentTier, onRequestTierChange }) {
27
+ const currentRank = getTierRank(currentTier);
28
+ return (
29
+ <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
30
+ {TIER_INFO.map((tier, i) => {
31
+ const active = i === currentRank;
32
+ const clickable = isTierClickable(currentTier, tier.key);
33
+ const reachable = clickable || active;
34
+ return (
35
+ <button
36
+ key={tier.key}
37
+ disabled={!clickable}
38
+ onClick={() => onRequestTierChange(tier.key)}
39
+ style={{
40
+ display: "flex", justifyContent: "space-between", alignItems: "center",
41
+ padding: "12px 14px", borderRadius: 10, textAlign: "left",
42
+ border: active ? "1px solid #c9a24b" : "1px solid #2a2d33",
43
+ background: active ? "#20242b" : "#16181c",
44
+ color: reachable ? "#e8e6e1" : "#55585e",
45
+ opacity: reachable ? 1 : 0.5, cursor: clickable ? "pointer" : "default",
46
+ }}
47
+ >
48
+ <span>
49
+ <div style={{ fontFamily: "ui-monospace, monospace", fontSize: 13, letterSpacing: 0.5 }}>
50
+ {i < currentRank ? "\u2713 " : active ? "\u25CF " : "\u25CB "}{tier.label.toUpperCase()}
51
+ </div>
52
+ <div style={{ fontSize: 12, color: "#8a8d93", marginTop: 2 }}>{tier.desc}</div>
53
+ </span>
54
+ {active && <span style={{ fontSize: 11, color: "#c9a24b" }}>ACTIVE</span>}
55
+ </button>
56
+ );
57
+ })}
58
+ </div>
59
+ );
60
+ }
61
+
62
+ function NumberRow({ label, value, source, mono = true }) {
63
+ const sourceColor = source === "walk_forward_validated" ? "#5fb87a" : source === "conservative_default" ? "#8a8d93" : "#c9a24b";
64
+ return (
65
+ <div style={{ display: "flex", justifyContent: "space-between", padding: "6px 0", borderBottom: "1px solid #21242a" }}>
66
+ <span style={{ fontSize: 13, color: "#a8abb1" }}>{label}</span>
67
+ <span style={{ display: "flex", alignItems: "center", gap: 6 }}>
68
+ <span style={{ fontFamily: mono ? "ui-monospace, monospace" : "inherit", fontSize: 13, color: "#e8e6e1" }}>{value}</span>
69
+ {source && <span style={{ width: 6, height: 6, borderRadius: "50%", background: sourceColor }} title={source} />}
70
+ </span>
71
+ </div>
72
+ );
73
+ }
74
+
75
+ export default function Dashboard({ gate, ledgerSupport }) {
76
+ const [snapshot, setSnapshot] = useState(null);
77
+ const [error, setError] = useState(null);
78
+ const [loading, setLoading] = useState(true);
79
+ const [tier, setTier] = useState(gate.tier);
80
+ const [automatedModalOpen, setAutomatedModalOpen] = useState(false);
81
+
82
+ const refresh = useCallback(async () => {
83
+ try {
84
+ setLoading(true);
85
+ const s = await gate.getDashboardSnapshot();
86
+ setSnapshot(s);
87
+ setError(null);
88
+ } catch (e) {
89
+ setError(e.message);
90
+ } finally {
91
+ setLoading(false);
92
+ }
93
+ }, [gate]);
94
+
95
+ useEffect(() => { refresh(); }, [refresh]);
96
+
97
+ const handleTierChange = (key) => {
98
+ if (key === TIERS.DASHBOARD) {
99
+ // FIXED -- this button rendered clickable at manual/automated tiers
100
+ // (see TierLadder's `reachable` check) but had no matching branch
101
+ // here, so tapping it silently did nothing. Confirmed empirically
102
+ // before this fix, not assumed. De-escalating trust should never
103
+ // be the hard path, and "hard path" turns out to have included
104
+ // "doesn't work at all."
105
+ gate.disableTrading();
106
+ setTier(gate.tier);
107
+ } else if (key === TIERS.MANUAL) {
108
+ gate.enableManual();
109
+ setTier(gate.tier);
110
+ } else if (key === TIERS.AUTOMATED) {
111
+ setAutomatedModalOpen(true);
112
+ }
113
+ };
114
+
115
+ const handleConfirmAutomated = async ({ maxPerTradeUsd, maxDailyTradesCount }) => {
116
+ // Let a thrown TradeGateError propagate -- AutomatedSetupModal catches
117
+ // it and displays it inline. Only close the modal and advance tier
118
+ // state on genuine success, so a rejected attempt never looks like it
119
+ // silently succeeded.
120
+ gate.enableAutomated({ maxPerTradeUsd, maxDailyTradesCount });
121
+ setTier(gate.tier);
122
+ setAutomatedModalOpen(false);
123
+ };
124
+
125
+ const usdBalance = snapshot?.balances?.find((b) => b.asset === "USD")?.available ?? null;
126
+
127
+ return (
128
+ <div style={{ background: "#0d0f12", minHeight: "100%", padding: 16, fontFamily: "system-ui, sans-serif", color: "#e8e6e1" }}>
129
+ <div style={{ marginBottom: 18 }}>
130
+ <div style={{ fontSize: 12, color: "#8a8d93", letterSpacing: 1 }}>ASSET</div>
131
+ <div style={{ fontSize: 22, fontFamily: "ui-monospace, monospace" }}>{gate.asset.toUpperCase()}</div>
132
+ </div>
133
+
134
+ {!ledgerSupport.supported && (
135
+ <div style={{ background: "#2a1f16", border: "1px solid #5a4426", borderRadius: 8, padding: 10, marginBottom: 14, fontSize: 12, color: "#d9b26a" }}>
136
+ Ledger unavailable in this browser: {ledgerSupport.reason}
137
+ </div>
138
+ )}
139
+
140
+ {error && (
141
+ <div style={{ background: "#2a1616", border: "1px solid #5a2626", borderRadius: 8, padding: 10, marginBottom: 14, fontSize: 12, color: "#e08a8a" }}>
142
+ {error}
143
+ </div>
144
+ )}
145
+
146
+ <div style={{ background: "#16181c", border: "1px solid #21242a", borderRadius: 12, padding: 14, marginBottom: 16 }}>
147
+ <NumberRow label="Price" value={loading ? "\u2026" : `$${snapshot?.price?.toFixed(4) ?? "--"}`} />
148
+ {snapshot?.balances?.map((b) => (
149
+ <NumberRow key={b.asset} label={`Balance (${b.asset})`} value={b.available.toFixed(b.asset === "USD" ? 2 : 6)} />
150
+ ))}
151
+ </div>
152
+
153
+ <div style={{ fontSize: 12, color: "#8a8d93", letterSpacing: 1, marginBottom: 8 }}>CAPABILITY</div>
154
+ <TierLadder currentTier={tier} onRequestTierChange={handleTierChange} />
155
+
156
+ <div style={{ marginTop: 20, fontSize: 11, color: "#5a5d63", display: "flex", gap: 10 }}>
157
+ <span><span style={{ color: "#5fb87a" }}>\u25CF</span> walk-forward validated</span>
158
+ <span><span style={{ color: "#8a8d93" }}>\u25CF</span> conservative default</span>
159
+ </div>
160
+
161
+ <AutomatedSetupModal
162
+ open={automatedModalOpen}
163
+ onCancel={() => setAutomatedModalOpen(false)}
164
+ onConfirm={handleConfirmAutomated}
165
+ usdBalance={usdBalance}
166
+ />
167
+ </div>
168
+ );
169
+ }
README.md CHANGED
@@ -1,2 +1,115 @@
1
- # Sentinel-Witness
2
- A path forward for morally good people to contribute to and grow
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Ledger Trading Dashboard
2
+
3
+ Phone-first PWA: Ledger hardware wallet + Coinbase/Kraken/Crypto.com, staged
4
+ dashboard → manual-approval → automated execution, driven by walk-forward-
5
+ validated grid/bracket strategy parameters (see the companion `strategy/`
6
+ Python project) with a real-time adaptive layer on top.
7
+
8
+ ## What's actually tested vs. what needs your device
9
+
10
+ This was built in a sandboxed environment with no access to Coinbase/
11
+ Kraken/Crypto.com's APIs, no WebHID, no real Ledger. That shaped what
12
+ "tested" can honestly mean here:
13
+
14
+ | Module | Tested | How |
15
+ |---|---|---|
16
+ | `secureStorage.js` | Yes | Full encrypt/decrypt round trip, wrong-passphrase-fails-closed, fresh salt/IV per save — real IndexedDB via `fake-indexeddb`, not mocked |
17
+ | `gridMath.js` | Yes | Lookahead safety, capital-safety invariant, parity against the Python engine's validated $12 dead-weight case, throttle-never-hits-zero |
18
+ | `exchanges/kraken.js`, `cryptocom.js` signing | Yes | Determinism, structural correctness (output length/format), key-order invariance |
19
+ | `exchanges/coinbase.js` JWT | Partially | ES256 JWT structure and signature cryptographically verified against a generated key pair — confirms the *mechanism* is right. Does **not** confirm a real Coinbase CDP key imports cleanly; that needs your actual key |
20
+ | `tradeGate.js` tier enforcement | Yes | Every limit-rejection path, tier-gating path, and the "no unlimited option" guarantee, via a mock exchange |
21
+ | `ledger.js` | No — cannot be | Needs a real browser (WebHID) and a real device |
22
+ | Live exchange calls (balances, order placement) | No — cannot be | This sandbox's network can't reach any of the three exchanges |
23
+ | UI (`Dashboard.jsx`, `App.jsx`) | No — cannot be | Needs a real browser |
24
+
25
+ Run `npm test` to re-run everything that *can* run outside a browser.
26
+
27
+ ## Setup
28
+
29
+ ```
30
+ npm install
31
+ npm run dev # local dev server
32
+ npm run build # production build, output in dist/
33
+ ```
34
+
35
+ ### Exchange credentials
36
+ - **Kraken**: create an API key with Query Funds + Create/Cancel orders
37
+ permissions (not Withdraw). Enter the key + secret in the app; they're
38
+ encrypted with your passphrase and stored only in this browser's
39
+ IndexedDB.
40
+ - **Crypto.com**: same shape (API key + secret), trading permissions only.
41
+ - **Coinbase**: create a CDP API key (Advanced Trade). The downloaded
42
+ private key must be PKCS8 PEM (`-----BEGIN PRIVATE KEY-----`). If yours
43
+ says `-----BEGIN EC PRIVATE KEY-----`, convert it first:
44
+ `openssl pkcs8 -topk8 -nocrypt -in your-key.pem -out converted.pem`
45
+
46
+ None of these should ever have withdrawal permission. This app only ever
47
+ needs to read balances and place/cancel limit orders.
48
+
49
+ ## Security model
50
+
51
+ - **Ledger**: signing happens on-device with a physical confirmation.
52
+ This code never sees a seed phrase or private key — see `ledger.js`'s
53
+ docstring. WebHID (required for the Ledger connection) works in Chrome/
54
+ Edge on desktop and Android; it does **not** work in any iOS browser,
55
+ including Safari-based ones on iPhone. On iOS, this needs a native app
56
+ with Ledger's Bluetooth SDK instead — flagged, not built here.
57
+ - **Exchange credentials**: AES-GCM encrypted with a key derived from
58
+ your passphrase (PBKDF2, 210k iterations), stored in this browser's
59
+ IndexedDB. This is real protection against someone reading files off
60
+ disk. It is **not** equivalent to a native app's OS Keychain/Keystore —
61
+ a PWA can't reach either. See `secureStorage.js` and the in-app
62
+ `SecurityNotice` component.
63
+ - **Trade execution guardrails**: all enforced in `tradeGate.js`, not
64
+ just documented — automated tier refuses to start without finite,
65
+ positive `maxPerTradeUsd` and `maxDailyTradesCount` (no unlimited
66
+ option), both re-checked on every signal, not just at setup. Every
67
+ execution path requires the Ledger confirmation step; there is no
68
+ code path that skips it.
69
+
70
+ ## What's genuinely not built yet
71
+
72
+ - **XRP unsigned-transaction-blob assembly.** `tradeGate.executeApprovedSignal()`
73
+ gets all the way to calling the Ledger and reading the address, then
74
+ throws an explicit `not_implemented` error rather than faking a
75
+ completed trade. Turning a `{pair, price, quantity}` signal into the
76
+ actual bytes `signXrpTransaction()` needs to sign (proper XRP Ledger
77
+ transaction encoding, sequence numbers, fees, submission to a node)
78
+ is real protocol work not done in this pass — flagged loudly rather
79
+ than stubbed quietly, per this whole project's standing rule about
80
+ silent failures being the dangerous kind.
81
+ - PWA icons (`icon-192.png`, `icon-512.png` referenced in `vite.config.js`)
82
+ aren't generated.
83
+ - Only XRP is wired through the UI/gate end-to-end; the strategy guide
84
+ covers DOGE too (see `src/lib/strategyGuide.js`) but the app only
85
+ asks for one asset right now.
86
+
87
+ ## Automated-tier setup
88
+
89
+ `AutomatedSetupModal.jsx` replaced the earlier `prompt()`/`alert()`
90
+ placeholder. Real form, real inline validation (mirrors
91
+ `tradeGate.js`'s own `enableAutomated()` check exactly — see
92
+ `src/lib/automatedLimits.js` — so the UI never blocks something the gate
93
+ would allow or vice versa), and a live "worst-case daily deployment"
94
+ figure (`maxPerTradeUsd × maxDailyTradesCount`, related to current USD
95
+ balance when known) so the two numbers you're locking in mean something
96
+ concrete before you confirm, not just two blank fields. Covered by
97
+ `src/lib/__tests__/automatedLimits.test.js`.
98
+
99
+ While wiring this in, found and fixed a second bug: the "Dashboard" tier
100
+ button rendered clickable while at manual/automated tier (stepping down
101
+ is deliberately always open, from any tier — see `tierNavigation.js`),
102
+ but `handleTierChange` had no branch for it, so tapping it silently did
103
+ nothing. Confirmed empirically before fixing (not assumed), and now
104
+ covered by `src/lib/__tests__/tierNavigation.test.js`, which asserts the
105
+ dashboard-tier case directly rather than just the fields it happened to
106
+ break in.
107
+
108
+ ## Re-generating the strategy guide
109
+
110
+ `src/lib/strategyGuide.js` embeds a snapshot from
111
+ `recursive_strategy_optimizer.py` (the Python walk-forward optimizer, in
112
+ the companion `strategy/` project — re-run that periodically, it's
113
+ designed for it). Either replace the `BAKED_IN_GUIDE` constant with fresh
114
+ output, or host the JSON somewhere and call `refreshGuide(url)` so the
115
+ app picks up updates without a rebuild.
automatedLimits.js ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * automatedLimits.js
3
+ *
4
+ * Pure logic behind AutomatedSetupModal.jsx, pulled out of the component
5
+ * for the same reason gridMath.js and strategyGuide.js already live here
6
+ * rather than inside a .jsx file: this project's test runner only
7
+ * executes plain .js under src/lib/__tests__/ (no JSX transform in that
8
+ * path, by design -- see package.json's "test" script), so logic left
9
+ * inline in a component is logic that never gets a regression test.
10
+ * That gap is exactly how Dashboard.jsx's dead dashboard-tier button
11
+ * shipped unnoticed -- see tierNavigation.js for the other half of that
12
+ * fix.
13
+ *
14
+ * isValidLimit() intentionally mirrors tradeGate.js's own enableAutomated()
15
+ * check (finite, > 0, nothing stricter) rather than inventing a separate
16
+ * rule -- if this ever drifted from the gate's actual check, the UI could
17
+ * either block something the gate would allow, or (worse) look valid
18
+ * here and still throw at the gate. Kept identical on purpose.
19
+ */
20
+
21
+ export function isValidLimit(raw) {
22
+ if (typeof raw !== "string" || raw.trim() === "") return false;
23
+ const n = Number(raw);
24
+ return Number.isFinite(n) && n > 0;
25
+ }
26
+
27
+ /** Worst-case, not expected-case: what it would mean if every single
28
+ * trade, every day, hit the per-trade cap. Returns null rather than NaN
29
+ * or 0 when inputs aren't both valid yet, so a caller can't accidentally
30
+ * render "$0" and have it read as a real answer. */
31
+ export function computeMaxDailyExposure(maxPerTradeUsd, maxDailyTradesCount) {
32
+ if (!isValidLimit(String(maxPerTradeUsd)) || !isValidLimit(String(maxDailyTradesCount))) return null;
33
+ return Number(maxPerTradeUsd) * Number(maxDailyTradesCount);
34
+ }
35
+
36
+ /** null when there's no balance to relate the exposure to yet (still
37
+ * loading, or a zero/missing USD balance) -- distinguished from 0 for
38
+ * the same "don't render a fake-looking real number" reason as above. */
39
+ export function computeExposurePctOfBalance(maxDailyExposure, usdBalance) {
40
+ if (maxDailyExposure === null || !Number.isFinite(usdBalance) || usdBalance <= 0) return null;
41
+ return (maxDailyExposure / usdBalance) * 100;
42
+ }
covenant_trading_bridge.py ADDED
@@ -0,0 +1,218 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ covenant_trading_bridge.py -- NEW. Connects the grid/bracket trading
4
+ strategy (covenant/strategy/, ledger-app/) to Covenant's real ledger,
5
+ ethics gate, and succession systems. Kept as a SEPARATE module rather
6
+ than folded into covenant_unified_v8.py itself: that file's own internal
7
+ organization already separates concerns into focused classes
8
+ (StakingPool, SuccessionGuardianSystem, CovenantGuardian, ...) combined
9
+ by CovenantUnifiedMaster -- trading-bridge logic is one more such
10
+ concern, not core blockchain/governance machinery, and keeping it
11
+ separate means covenant_unified_v8.py's already-long patch-log history
12
+ doesn't have to absorb a domain that isn't its own. One small, clearly-
13
+ delineated route is added to CovenantAPI (see the PATCH LOG v8.6 block
14
+ in covenant_unified_v8.py) so this bridge is reachable over the same
15
+ running API, not a second server to operate.
16
+
17
+ WHY PROFIT, NOT EVERY FILL, IS WHAT GETS RECORDED: a BUY converts USD
18
+ held at an exchange into an asset held at the same exchange -- it isn't
19
+ new value entering the system, it's an external asset swap Covenant has
20
+ no natural way to represent (the existing Transaction model moves
21
+ EXISTING sender balance to a receiver; a buy has no Covenant-side
22
+ "sender" with that balance, because the balance lives at Kraken/Coinbase/
23
+ Crypto.com, not on this ledger). A completed round-trip SELL is
24
+ different: run_grid_backtest's own fill discipline (grid_engine.py) only
25
+ closes a position above its own cost basis, so a realized profit is a
26
+ genuine new economic fact, structurally analogous to genesis's one-time
27
+ mint -- not a transfer between two Covenant parties. Modeled that way
28
+ here (record_ledger_entry with reason="trading_profit", gated by a real
29
+ signature) rather than forced through the peer-to-peer Transaction/mining
30
+ pipeline, which would need a fictional zero-balance "sender" and either
31
+ fail its own balance check or require yet another hasattr-style special
32
+ case -- exactly the fail-open shape this codebase's own patch log (items
33
+ H, and generally) has spent several rounds closing elsewhere. Consistent
34
+ with genesis's mint, but NOT unconditional the way genesis's one-time
35
+ bootstrap mint is: every credit here requires a real signature from the
36
+ pool's own key, verified before the ledger entry is written.
37
+
38
+ WHY A GIFT IS A LEDGER CREDIT, NOT AN AUTO-STAKE: Covenant's own /stake
39
+ route requires the STAKER's own signature (verify_stake_signature) --
40
+ auto-staking on a new node's behalf using the POOL's authority would
41
+ mean staking without the actual keyholder's consent, a real regression
42
+ against a pattern this file enforces everywhere else. A gift here credits
43
+ the recipient's spendable balance; if they want it staked, they submit
44
+ their own signed /stake call, same as anyone else.
45
+
46
+ SCOPE BOUNDARY, STATED PLAINLY: SuccessionGuardianSystem registration
47
+ below covers who is authorized to sign FUTURE Covenant-ledger entries for
48
+ the trading pool -- a software/cryptographic fact this code can actually
49
+ enforce. It does NOT and cannot transfer control of the real exchange
50
+ accounts (Coinbase/Kraken/Crypto.com) or the physical Ledger hardware
51
+ wallet those accounts' keys ultimately depend on. That is real-world
52
+ estate planning (a will, a secured note with recovery details, whatever
53
+ the person's jurisdiction requires) outside what any code here can
54
+ enforce. Do not let a clean succession registration on this side create
55
+ the impression the whole operation's succession is handled -- it is one
56
+ necessary piece, not the whole picture.
57
+ """
58
+
59
+ import sys
60
+ import time
61
+ sys.path.insert(0, "/home/claude/covenant")
62
+
63
+ from covenant_unified_v8 import (
64
+ _domain_frame, Database, StakingPool, SuccessionGuardianSystem,
65
+ ReasoningSentinel, JudgmentResult, TradingBridgeError,
66
+ )
67
+ from cryptography.hazmat.primitives import hashes, serialization
68
+ from cryptography.hazmat.primitives.asymmetric import padding
69
+ from cryptography.hazmat.backends import default_backend
70
+ import base64
71
+
72
+
73
+ def trading_profit_payload(pool_pubkey: str, asset: str, exchange: str, external_ref: str,
74
+ pnl_usd: float, timestamp: float) -> bytes:
75
+ return _domain_frame(b"COVENANT_TRADING_PROFIT_V1", pool_pubkey, asset, exchange,
76
+ external_ref, str(pnl_usd), str(timestamp))
77
+
78
+
79
+ def verify_trading_profit_signature(pool_pubkey_pem: str, asset: str, exchange: str, external_ref: str,
80
+ pnl_usd: float, timestamp: float, signature_b64: str) -> bool:
81
+ try:
82
+ payload = trading_profit_payload(pool_pubkey_pem, asset, exchange, external_ref, pnl_usd, timestamp)
83
+ pub_key = serialization.load_pem_public_key(pool_pubkey_pem.encode(), backend=default_backend())
84
+ pub_key.verify(
85
+ base64.b64decode(signature_b64), payload,
86
+ padding.PSS(mgf=padding.MGF1(hashes.SHA256()), salt_length=padding.PSS.MAX_LENGTH),
87
+ hashes.SHA256(),
88
+ )
89
+ return True
90
+ except Exception:
91
+ return False
92
+
93
+
94
+ def node_gift_payload(pool_pubkey: str, recipient_pubkey: str, amount: float, timestamp: float) -> bytes:
95
+ return _domain_frame(b"COVENANT_NODE_GIFT_V1", pool_pubkey, recipient_pubkey, str(amount), str(timestamp))
96
+
97
+
98
+ def verify_node_gift_signature(pool_pubkey_pem: str, recipient_pubkey: str, amount: float,
99
+ timestamp: float, signature_b64: str) -> bool:
100
+ try:
101
+ payload = node_gift_payload(pool_pubkey_pem, recipient_pubkey, amount, timestamp)
102
+ pub_key = serialization.load_pem_public_key(pool_pubkey_pem.encode(), backend=default_backend())
103
+ pub_key.verify(
104
+ base64.b64decode(signature_b64), payload,
105
+ padding.PSS(mgf=padding.MGF1(hashes.SHA256()), salt_length=padding.PSS.MAX_LENGTH),
106
+ hashes.SHA256(),
107
+ )
108
+ return True
109
+ except Exception:
110
+ return False
111
+
112
+
113
+ class TradingBridge:
114
+ """
115
+ Wraps a running Covenant node's db/sentinel/staking_pool/succession
116
+ with the two trading-specific operations (report_realized_profit,
117
+ gift_stake_to_new_node) plus a succession-registration convenience
118
+ wrapper. Constructed with the SAME objects CovenantUnifiedMaster
119
+ already built -- this class adds behavior, it does not stand up a
120
+ second, parallel copy of the ledger/db/succession system.
121
+ """
122
+
123
+ def __init__(self, db: Database, sentinel: ReasoningSentinel, staking_pool: StakingPool,
124
+ succession: SuccessionGuardianSystem):
125
+ self.db = db
126
+ self.sentinel = sentinel
127
+ self.staking_pool = staking_pool
128
+ self.succession = succession
129
+
130
+ def report_realized_profit(self, pool_pubkey_pem: str, asset: str, exchange: str, external_ref: str,
131
+ pnl_usd: float, timestamp: float, signature_b64: str) -> dict:
132
+ """
133
+ Credits `pnl_usd` to the trading pool's Covenant balance, gated
134
+ by a real signature over (pool_pubkey, asset, exchange,
135
+ external_ref, pnl_usd, timestamp) -- see module docstring for why
136
+ this is a signed mint-style credit, not a peer-to-peer
137
+ Transaction. `external_ref` (e.g. an exchange order id) plus
138
+ timestamp double as replay protection via the caller's own
139
+ nonce-seen table, same pattern as every other signed action in
140
+ this codebase (see /claim_rewards, /unstake) -- enforced at the
141
+ API route (see PATCH LOG v8.6 in covenant_unified_v8.py), not
142
+ re-implemented here, so there's one nonce-checking pattern in the
143
+ system, not two slightly-different ones.
144
+ """
145
+ if pnl_usd <= 0:
146
+ raise TradingBridgeError(
147
+ f"report_realized_profit called with pnl_usd={pnl_usd} <= 0 -- this bridge only "
148
+ f"records REALIZED PROFIT (see module docstring); a non-positive value means either "
149
+ f"a losing trade (which this strategy's take-profit gating should make structurally "
150
+ f"impossible for a CLOSED trade -- see grid_engine.py's own note on this) or a caller "
151
+ f"bug. Refusing rather than writing a nonsensical ledger entry."
152
+ )
153
+ if not verify_trading_profit_signature(pool_pubkey_pem, asset, exchange, external_ref,
154
+ pnl_usd, timestamp, signature_b64):
155
+ raise TradingBridgeError("Invalid trading-profit signature -- refusing to credit the ledger.")
156
+
157
+ # Audit-trail judgment, NOT a hard gate -- see module docstring on
158
+ # why this is soft here specifically. A profit report has no
159
+ # peer-to-peer "sender" content to judge for the kind of thing
160
+ # MockJudge's keyword scan is even meant to catch; logging the
161
+ # judgment keeps the same audit pattern the rest of the system
162
+ # uses without pretending a hard gate here would mean something
163
+ # it doesn't.
164
+ judgment = self.sentinel.judge.evaluate(
165
+ {"origin": "trading_bracket_grid", "asset": asset, "exchange": exchange,
166
+ "external_ref": external_ref, "pnl_usd": pnl_usd},
167
+ self.sentinel.principles,
168
+ )
169
+ ref_id = f"trading_profit:{exchange}:{external_ref}"
170
+ self.db.save_judgment(ref_id, judgment)
171
+ self.db.record_ledger_entry(pool_pubkey_pem, pnl_usd, "trading_profit", ref_id=ref_id)
172
+
173
+ return {
174
+ "credited": pnl_usd, "new_balance": self.db.get_balance(pool_pubkey_pem),
175
+ "judgment": judgment.reasoning, "ref_id": ref_id,
176
+ }
177
+
178
+ def gift_stake_to_new_node(self, pool_pubkey_pem: str, recipient_pubkey_pem: str, amount: float,
179
+ timestamp: float, signature_b64: str) -> dict:
180
+ """
181
+ Non-usurious by construction: a straight ledger credit, zero
182
+ consideration, no interest, no repayment obligation -- reason
183
+ string is "node_gift", deliberately distinct from "stake_lock"
184
+ or anything loan-shaped. Requires the POOL's real signature
185
+ (proving the operator authorized this specific gift, to this
186
+ specific recipient, for this specific amount) -- an attacker who
187
+ merely knows a recipient pubkey cannot drain the pool by calling
188
+ this. Does NOT stake on the recipient's behalf -- see module
189
+ docstring; they stake it themselves via the existing signed
190
+ /stake route if they choose to.
191
+ """
192
+ if amount <= 0:
193
+ raise TradingBridgeError("Gift amount must be positive.")
194
+ if not verify_node_gift_signature(pool_pubkey_pem, recipient_pubkey_pem, amount, timestamp, signature_b64):
195
+ raise TradingBridgeError("Invalid node-gift signature -- refusing to move funds.")
196
+ balance = self.db.get_balance(pool_pubkey_pem)
197
+ if balance < amount:
198
+ raise TradingBridgeError(f"Insufficient pool balance: have {balance:.2f}, need {amount:.2f}.")
199
+
200
+ ref_id = f"node_gift:{pool_pubkey_pem[:16]}:{recipient_pubkey_pem[:16]}:{timestamp}"
201
+ self.db.record_ledger_entry(pool_pubkey_pem, -amount, "node_gift_sent", ref_id=ref_id)
202
+ self.db.record_ledger_entry(recipient_pubkey_pem, amount, "node_gift_received", ref_id=ref_id)
203
+
204
+ return {
205
+ "gifted": amount, "pool_balance_after": self.db.get_balance(pool_pubkey_pem),
206
+ "recipient_balance_after": self.db.get_balance(recipient_pubkey_pem), "ref_id": ref_id,
207
+ }
208
+
209
+ def register_pool_succession(self, pool_pubkey_pem: str, successor_pubkey_pem: str,
210
+ guardian_pubkeys: list, threshold: int,
211
+ heartbeat_interval_days: float = 30, grace_period_days: float = 15) -> tuple:
212
+ """Thin, documented wrapper -- see module docstring's SCOPE
213
+ BOUNDARY before treating this as complete succession coverage
214
+ for the trading operation as a whole."""
215
+ return self.succession.register(
216
+ pool_pubkey_pem, successor_pubkey_pem, guardian_pubkeys,
217
+ threshold, heartbeat_interval_days, grace_period_days,
218
+ )
covenant_unified_v8.py CHANGED
@@ -197,6 +197,84 @@ PATCH LOG — v7.2 (balance ledger + /stake signature requirement)
197
  Fixed: the route now reads both fields from the request body.
198
  """
199
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
200
  import json
201
  import time
202
  import hashlib
@@ -217,7 +295,34 @@ from cryptography.hazmat.primitives import hashes, serialization
217
  from cryptography.hazmat.primitives.asymmetric import rsa, padding
218
  from cryptography.hazmat.backends import default_backend
219
  from flask import Flask, request, jsonify
220
- from werkzeug.serving import run_simple # <- the import weird_science.py forgot
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
221
 
222
  # ---------------------------------------------------------------------------
223
  # Constants
@@ -251,6 +356,20 @@ BASE_REGISTRATION_DIFFICULTY = 2
251
  # that never matched request.endpoint ("add_transaction"/"add_peer") except
252
  # for "mine", which happened to match by coincidence. Fixed here.
253
  RATE_LIMIT_DEFAULT = 20 # per 60s, unlisted/read endpoints
 
 
 
 
 
 
 
 
 
 
 
 
 
 
254
  RATE_LIMIT = {
255
  "add_transaction": 10,
256
  "mine": 1,
@@ -2531,6 +2650,7 @@ class P2PNode:
2531
  self.friendship: Optional[FriendshipTracker] = None
2532
  self.staking_pool: Optional[StakingPool] = None
2533
  self.succession: Optional[SuccessionGuardianSystem] = None
 
2534
  self.code_guardian: "CovenantGuardian" = CovenantGuardian() # NEW v8.0
2535
  self.rate_limiter = RateLimiter()
2536
  self.adaptive_pow_manager = AdaptivePoWManager(db) if ADAPTIVE_POW else None
@@ -2565,6 +2685,28 @@ class P2PNode:
2565
  for pid, (host, port) in peers:
2566
  threading.Thread(target=self._send_raw, args=(host, port, json.dumps(message)), daemon=True).start()
2567
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2568
  def _send_raw(self, host: str, port: int, data: str):
2569
  try:
2570
  with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
@@ -2980,8 +3122,79 @@ class CovenantAPI:
2980
  self.node.crisis_reason = ""
2981
  return jsonify({"status": "success", "message": "crisis_mode cleared"})
2982
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2983
  def run(self):
2984
- run_simple(self.host, self.port, self.app, threaded=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2985
 
2986
 
2987
  # ---------------------------------------------------------------------------
@@ -3015,6 +3228,17 @@ class CovenantUnifiedMaster:
3015
  self.node.friendship = FriendshipTracker(self.db)
3016
  self.node.staking_pool = StakingPool(self.db)
3017
  self.node.succession = SuccessionGuardianSystem(self.db)
 
 
 
 
 
 
 
 
 
 
 
3018
 
3019
  self.node.chain = self.db.load_chain()
3020
  if self.node.chain:
 
197
  Fixed: the route now reads both fields from the request body.
198
  """
199
 
200
+ # ---------------------------------------------------------------------------
201
+ # PATCH LOG -- v8.6: TRADING BRIDGE -- REALIZED TRADING PROFIT BECOMES REAL
202
+ # COVENANT LEDGER VALUE, FUNDING THE NODE-GIFTING STRUCTURE
203
+ # ----------------------------------------------------------------
204
+ # N. Connects the grid/bracket trading strategy (separate project:
205
+ # strategy/, ledger-app/) to this ledger. Implemented in a SEPARATE
206
+ # module, covenant_trading_bridge.py, not folded into this file --
207
+ # see that module's docstring for why. Two routes added below, both
208
+ # requiring the same domain-tagged RSA-PSS signature scheme
209
+ # (_domain_frame) every other value-moving action here already uses,
210
+ # plus nonce-based replay protection matching /claim_rewards and
211
+ # /unstake:
212
+ # POST /trading/report_profit -- credits realized trading P&L to
213
+ # the pool's ledger balance. A signed, mint-style credit (like
214
+ # genesis's one-time mint, but gated by a real signature EVERY
215
+ # time, never unconditional) rather than forced through the
216
+ # peer-to-peer Transaction/mining pipeline, which has no natural
217
+ # "sender" for value that originated outside Covenant entirely.
218
+ # POST /trading/gift -- the actual node-gifting mechanism: a
219
+ # straight ledger credit, zero consideration, reason=
220
+ # "node_gift" (deliberately not "stake_lock" or anything
221
+ # loan-shaped) -- non-usurious by construction. Does NOT
222
+ # auto-stake for the recipient; they stake it themselves via the
223
+ # existing signed /stake route, consistent with this file's
224
+ # standing rule that staking requires the staker's own
225
+ # signature.
226
+ # VERIFIED EMPIRICALLY before wiring in (see covenant_trading_
227
+ # bridge.py's own test run): forged signatures rejected, negative/
228
+ # zero pnl rejected, over-balance gifts rejected, gifts confirmed
229
+ # NOT auto-staked, recipient confirmed able to stake their gift via
230
+ # the normal existing path.
231
+ # SCOPE BOUNDARY, NOT SOLVED HERE: SuccessionGuardianSystem
232
+ # registration for the pool's key (already usable, zero changes
233
+ # needed) makes WHO CAN SIGN future ledger entries for the trading
234
+ # pool a real, enforceable cryptographic fact. It does NOT and
235
+ # cannot transfer control of the actual exchange accounts or the
236
+ # physical Ledger hardware wallet those accounts ultimately depend
237
+ # on -- that is real-world estate planning outside anything code can
238
+ # enforce. Flagged loudly rather than implied solved.
239
+ # ---------------------------------------------------------------------------
240
+
241
+ # ---------------------------------------------------------------------------
242
+ # PATCH LOG -- v8.7: PRODUCTION WSGI SERVER (waitress)
243
+ # ----------------------------------------------------------------
244
+ # O. Requested: replace werkzeug's run_simple (module docstring's own
245
+ # FOUR FATAL BUGS item 2 is literally about this same call site --
246
+ # weird_science.py originally forgot to import it at all) with a
247
+ # real production WSGI server. run_simple prints its own warning on
248
+ # every start ("This is a development server. Do not use it in a
249
+ # production deployment.") -- that warning was accurate, not
250
+ # decorative, and this closes it. Chosen: waitress -- pure Python,
251
+ # no C-extension build step (relevant since run_sandboxed() already
252
+ # documents a POSIX-only fork() constraint elsewhere in this file;
253
+ # not adding a second platform constraint via the WSGI layer too),
254
+ # actively maintained, and its defaults are sane enough that most of
255
+ # the "production hardening" is just making waitress's own existing
256
+ # defaults explicit in the serve() call (see CovenantAPI.run())
257
+ # rather than inventing new configuration surface.
258
+ # NOT SOLVED BY THIS CHANGE, STATED PLAINLY SO IT ISN'T ASSUMED FIXED:
259
+ # /mine's proof-of-work runs synchronously in whichever waitress
260
+ # thread handles that request -- real CPU-bound work under the GIL,
261
+ # which more WSGI threads does not parallelize (see WSGI_THREADS'S
262
+ # own comment). And CovenantAPI.host still defaults to "0.0.0.0" --
263
+ # unchanged in this pass, deliberately: that's a distinct, already-
264
+ # flagged decision (network exposure given this file's own
265
+ # documented "NO API AUTHENTICATION ANYWHERE") separate from which
266
+ # WSGI server answers the socket, and bundling an unrequested
267
+ # default change into a response to a specific "use waitress"
268
+ # instruction risks exactly the kind of silent scope creep this
269
+ # file's own patch log has criticized elsewhere. Left for its own
270
+ # explicit decision.
271
+ # VERIFIED EMPIRICALLY: a real waitress-served instance started,
272
+ # confirmed no dev-server warning in output, confirmed a real HTTP
273
+ # round-trip (GET /chain) still succeeds -- see session notes.
274
+ # ---------------------------------------------------------------------------
275
+
276
+
277
+
278
  import json
279
  import time
280
  import hashlib
 
295
  from cryptography.hazmat.primitives.asymmetric import rsa, padding
296
  from cryptography.hazmat.backends import default_backend
297
  from flask import Flask, request, jsonify
298
+ from waitress import serve # PRODUCTION WSGI SERVER -- see PATCH LOG v8.7. Replaces
299
+ # werkzeug's run_simple, which prints its own warning that
300
+ # it is a development server not meant for production use.
301
+
302
+ # NEW v8.6 -- TradingBridgeError defined HERE, not imported. The prior
303
+ # version of this block did `try: from covenant_trading_bridge import
304
+ # TradingBridgeError except ImportError: class TradingBridgeError...` --
305
+ # confirmed by a live HTTP test (not by reading the code) to silently
306
+ # create TWO DISTINCT class objects: covenant_trading_bridge.py imports
307
+ # Database/StakingPool/SuccessionGuardianSystem/etc. FROM this file, so
308
+ # when THIS file's top-level import tried to pull TradingBridgeError from
309
+ # covenant_trading_bridge before those classes were even defined yet
310
+ # (this import sits above their definitions), the circular import failed
311
+ # with ImportError every time, silently triggering the fallback class
312
+ # instead of the real one. Every /trading/gift and /trading/report_fill
313
+ # error path then raised the REAL TradingBridgeError (from
314
+ # covenant_trading_bridge.py, imported successfully later via the lazy
315
+ # import in CovenantUnifiedMaster.__init__, by which point this module
316
+ # had finished loading) while the route's `except TradingBridgeError`
317
+ # was bound to the FALLBACK class -- so it never matched, and every
318
+ # rejected trading-bridge request surfaced as an unhandled 500 instead of
319
+ # the intended 400 with a clear message. Confirmed via id() comparison:
320
+ # the two classes were different objects, same name. Fixed by defining
321
+ # it once, here, and having covenant_trading_bridge.py import THIS one
322
+ # (see that file's own import list) instead of each file trying to
323
+ # obtain it from the other.
324
+ class TradingBridgeError(Exception):
325
+ pass
326
 
327
  # ---------------------------------------------------------------------------
328
  # Constants
 
356
  # that never matched request.endpoint ("add_transaction"/"add_peer") except
357
  # for "mine", which happened to match by coincidence. Fixed here.
358
  RATE_LIMIT_DEFAULT = 20 # per 60s, unlisted/read endpoints
359
+
360
+ # NEW v8.7 -- waitress's thread pool size. NOTE, stated plainly rather than
361
+ # implied solved by "production WSGI server": /mine runs real proof-of-work
362
+ # (block.mine() at MINING_DIFFICULTY) directly in the request-handling
363
+ # thread. That's genuine CPU-bound work under CPython's GIL -- more
364
+ # waitress threads does NOT parallelize it, it only lets OTHER requests
365
+ # (reads, non-mining writes) continue being served by a different thread
366
+ # while one thread is busy mining. If concurrent mining throughput ever
367
+ # becomes a real requirement, that needs mining moved to a separate
368
+ # process (e.g. via multiprocessing, same pattern run_sandboxed() already
369
+ # uses for code proposals), not a thread-count tweak. Not done here --
370
+ # flagged, not fixed, consistent with this file's own standing rule.
371
+ WSGI_THREADS = 6
372
+
373
  RATE_LIMIT = {
374
  "add_transaction": 10,
375
  "mine": 1,
 
2650
  self.friendship: Optional[FriendshipTracker] = None
2651
  self.staking_pool: Optional[StakingPool] = None
2652
  self.succession: Optional[SuccessionGuardianSystem] = None
2653
+ self.trading_bridge = None # NEW v8.6 -- see covenant_trading_bridge.py
2654
  self.code_guardian: "CovenantGuardian" = CovenantGuardian() # NEW v8.0
2655
  self.rate_limiter = RateLimiter()
2656
  self.adaptive_pow_manager = AdaptivePoWManager(db) if ADAPTIVE_POW else None
 
2685
  for pid, (host, port) in peers:
2686
  threading.Thread(target=self._send_raw, args=(host, port, json.dumps(message)), daemon=True).start()
2687
 
2688
+ def propagate_trading_event(self, event_type: str, payload: dict):
2689
+ """NEW v8.6 -- closes the gap confirmed empirically while testing
2690
+ the trading bridge live: record_ledger_entry (used by
2691
+ report_realized_profit/gift_stake_to_new_node, following genesis
2692
+ mint's own pattern) only ever applies LOCALLY -- only /mine
2693
+ triggers propagate_block. That's the exact same gap already
2694
+ documented for staking (PATCH LOG item 9: 'no propagate_stake()
2695
+ anywhere'), now showing up here too, and closed the same way
2696
+ propagate_transaction already works: broadcast the full signed
2697
+ payload, and the RECEIVING node independently re-verifies the
2698
+ signature before applying anything -- never trusts a peer's
2699
+ claim that a signature was valid. event_type is "profit" or
2700
+ "gift"; payload carries everything _handle_peer needs to call
2701
+ the SAME bridge functions the original node called, so
2702
+ verification logic exists in exactly one place."""
2703
+ message = {"type": "TRADING_EVENT_PROPAGATE", "event_type": event_type, "payload": payload,
2704
+ "node_id": self.node_id, "nonce": f"{event_type}{payload.get('timestamp')}{secrets.token_hex(4)}"}
2705
+ with self.peers_lock:
2706
+ peers = list(self.peers.items())
2707
+ for pid, (host, port) in peers:
2708
+ threading.Thread(target=self._send_raw, args=(host, port, json.dumps(message)), daemon=True).start()
2709
+
2710
  def _send_raw(self, host: str, port: int, data: str):
2711
  try:
2712
  with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
 
3122
  self.node.crisis_reason = ""
3123
  return jsonify({"status": "success", "message": "crisis_mode cleared"})
3124
 
3125
+ # -------------------------------------------------------------
3126
+ # PATCH LOG -- v8.6: TRADING BRIDGE (see covenant_trading_bridge.py
3127
+ # for the full design rationale). Two routes, both requiring a real
3128
+ # signature from the trading pool's own key -- same authorization
3129
+ # model as /stake, /claim_rewards, /unstake: no separate API auth
3130
+ # layer, the signature over the specific action IS the auth.
3131
+ # Nonce/replay protection follows the exact pattern already used by
3132
+ # /claim_rewards and /unstake (nonce_key from action+params+
3133
+ # timestamp, checked via is_nonce_seen/mark_nonce_seen) rather than
3134
+ # inventing a second convention.
3135
+ # -------------------------------------------------------------
3136
+ @self.app.route("/trading/report_fill", methods=["POST"])
3137
+ def trading_report_fill():
3138
+ if self.node.trading_bridge is None:
3139
+ return jsonify({"status": "error", "message": "Trading bridge not available on this node (covenant_trading_bridge.py not importable)"}), 503
3140
+ data = request.json or {}
3141
+ pool_pubkey = data.get("pool_pubkey", "")
3142
+ asset = data.get("asset", "")
3143
+ exchange = data.get("exchange", "")
3144
+ external_ref = data.get("external_ref", "")
3145
+ pnl_usd = float(data.get("pnl_usd", 0.0))
3146
+ timestamp = float(data.get("timestamp", 0.0))
3147
+ signature = data.get("signature", "")
3148
+ nonce_key = f"trading_profit:{pool_pubkey}:{exchange}:{external_ref}:{timestamp}"
3149
+ if self.db.is_nonce_seen(nonce_key):
3150
+ return jsonify({"status": "error", "message": "Duplicate/replayed profit report"}), 400
3151
+ try:
3152
+ result = self.node.trading_bridge.report_realized_profit(
3153
+ pool_pubkey, asset, exchange, external_ref, pnl_usd, timestamp, signature
3154
+ )
3155
+ except TradingBridgeError as e:
3156
+ return jsonify({"status": "error", "message": str(e)}), 400
3157
+ self.db.mark_nonce_seen(nonce_key)
3158
+ return jsonify({"status": "success", **result})
3159
+
3160
+ @self.app.route("/trading/gift", methods=["POST"])
3161
+ def trading_gift():
3162
+ if self.node.trading_bridge is None:
3163
+ return jsonify({"status": "error", "message": "Trading bridge not available on this node (covenant_trading_bridge.py not importable)"}), 503
3164
+ data = request.json or {}
3165
+ pool_pubkey = data.get("pool_pubkey", "")
3166
+ recipient_pubkey = data.get("recipient_pubkey", "")
3167
+ amount = float(data.get("amount", 0.0))
3168
+ timestamp = float(data.get("timestamp", 0.0))
3169
+ signature = data.get("signature", "")
3170
+ nonce_key = f"node_gift:{pool_pubkey}:{recipient_pubkey}:{timestamp}"
3171
+ if self.db.is_nonce_seen(nonce_key):
3172
+ return jsonify({"status": "error", "message": "Duplicate/replayed gift"}), 400
3173
+ try:
3174
+ result = self.node.trading_bridge.gift_stake_to_new_node(
3175
+ pool_pubkey, recipient_pubkey, amount, timestamp, signature
3176
+ )
3177
+ except TradingBridgeError as e:
3178
+ return jsonify({"status": "error", "message": str(e)}), 400
3179
+ self.db.mark_nonce_seen(nonce_key)
3180
+ return jsonify({"status": "success", **result})
3181
+
3182
  def run(self):
3183
+ # PRODUCTION WSGI SERVER -- see PATCH LOG v8.7 (module docstring).
3184
+ # connection_limit and channel_timeout are waitress's own defaults
3185
+ # made explicit here rather than left implicit, so a future reader
3186
+ # doesn't have to go check waitress's source to know what's
3187
+ # actually in effect. clear_untrusted_proxy_headers=True is
3188
+ # waitress's default as of the version pinned in requirements.txt;
3189
+ # stated explicitly since silently relying on a library default
3190
+ # that could change between versions is the same "invisible
3191
+ # behavior" shape this file's own patch log has flagged elsewhere
3192
+ # (e.g. PATCH LOG item H's hasattr guards).
3193
+ serve(
3194
+ self.app, host=self.host, port=self.port, threads=WSGI_THREADS,
3195
+ connection_limit=100, channel_timeout=120,
3196
+ clear_untrusted_proxy_headers=True,
3197
+ )
3198
 
3199
 
3200
  # ---------------------------------------------------------------------------
 
3228
  self.node.friendship = FriendshipTracker(self.db)
3229
  self.node.staking_pool = StakingPool(self.db)
3230
  self.node.succession = SuccessionGuardianSystem(self.db)
3231
+ # NEW v8.6 -- see covenant_trading_bridge.py. Imported lazily here
3232
+ # (not at module top) so covenant_unified_v8.py has zero hard
3233
+ # dependency on the bridge module -- the core file must still
3234
+ # import and run standalone even if the bridge file is absent,
3235
+ # consistent with this module's own stated policy of not letting
3236
+ # one concern's presence become a silent requirement for another's.
3237
+ try:
3238
+ from covenant_trading_bridge import TradingBridge
3239
+ self.node.trading_bridge = TradingBridge(self.db, self.node.sentinel, self.node.staking_pool, self.node.succession)
3240
+ except ImportError:
3241
+ self.node.trading_bridge = None
3242
 
3243
  self.node.chain = self.db.load_chain()
3244
  if self.node.chain:
requirements.txt ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Core
2
+ flask>=3.0,<4.0
3
+ cryptography>=42.0,<44.0
4
+
5
+ # Production WSGI server -- see PATCH LOG v8.7 in covenant_unified_v8.py.
6
+ # Pure Python, no C-extension build step, works the same across platforms.
7
+ waitress>=3.0,<4.0
8
+
9
+ # Trading bridge test/dev dependency only -- not needed to run the node
10
+ # itself, only to exercise covenant_trading_bridge.py's HTTP-level tests.
11
+ requests>=2.31,<3.0
tierNavigation.js ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * tierNavigation.js
3
+ *
4
+ * Single source of truth for "which tier buttons are clickable from here."
5
+ * Pulled out of Dashboard.jsx's TierLadder deliberately: before this, the
6
+ * reachability rule lived inline in JSX, and handleTierChange's if/else
7
+ * chain lived a few lines below it as a SEPARATE, hand-maintained list --
8
+ * two things that had to agree with each other but had no mechanism
9
+ * forcing them to. Confirmed empirically (see tierNavigation.test.js)
10
+ * that they'd already drifted: the dashboard-tier button rendered
11
+ * clickable at manual/automated tiers, but handleTierChange had no
12
+ * matching branch, so tapping it silently did nothing. Same shape as
13
+ * every other "two things that must agree, drifted apart, one caller
14
+ * didn't notice" bug this project's patch logs keep finding elsewhere --
15
+ * fixed here the same way those were: one source of truth, not two
16
+ * copies kept in sync by hand.
17
+ */
18
+
19
+ import { TIERS } from "./tradeGate.js";
20
+
21
+ export const TIER_ORDER = [TIERS.DASHBOARD, TIERS.MANUAL, TIERS.AUTOMATED];
22
+
23
+ export function getTierRank(tierKey) {
24
+ return TIER_ORDER.indexOf(tierKey);
25
+ }
26
+
27
+ /**
28
+ * Every tier at or below (current + 1) is reachable -- stepping UP only
29
+ * ever unlocks the next rung, but stepping DOWN is always fully open,
30
+ * from any tier, in one tap. That asymmetry is deliberate (see
31
+ * tradeGate.js's own "de-escalating trust should never be the hard
32
+ * path"), not an oversight -- it's *why* dashboard is clickable from
33
+ * automated, which is exactly the case that had no handler.
34
+ */
35
+ export function getClickableTierKeys(currentTierKey) {
36
+ const currentRank = getTierRank(currentTierKey);
37
+ return TIER_ORDER.filter((key, i) => i !== currentRank && i <= currentRank + 1);
38
+ }
39
+
40
+ export function isTierClickable(currentTierKey, targetTierKey) {
41
+ return getClickableTierKeys(currentTierKey).includes(targetTierKey);
42
+ }