LAWLESS1987 commited on
Commit ·
3f9464a
1
Parent(s): 7666751
Add files via upload
Browse files- AutomatedSetupModal.jsx +242 -0
- Dashboard.jsx +169 -0
- README.md +115 -2
- automatedLimits.js +42 -0
- covenant_trading_bridge.py +218 -0
- covenant_unified_v8.py +226 -2
- requirements.txt +11 -0
- tierNavigation.js +42 -0
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 |
-
#
|
| 2 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
}
|