diff --git a/dakota-dashboard/.env.example b/dakota-dashboard/.env.example
new file mode 100644
index 00000000..b51468e5
--- /dev/null
+++ b/dakota-dashboard/.env.example
@@ -0,0 +1,7 @@
+# Staging (default when unset — see src/config.ts).
+VITE_DAKOTA_API=https://sui-options.com/staging/dakota
+VITE_AUTH_API=https://sui-options.com/staging/auth
+
+# Local dev against services on localhost:
+# VITE_DAKOTA_API=http://127.0.0.1:9019
+# VITE_AUTH_API=http://127.0.0.1:9007
diff --git a/dakota-dashboard/.gitignore b/dakota-dashboard/.gitignore
new file mode 100644
index 00000000..968461d4
--- /dev/null
+++ b/dakota-dashboard/.gitignore
@@ -0,0 +1,7 @@
+node_modules/
+dist/
+.vercel/
+*.tsbuildinfo
+.env
+.env.*
+!.env.example
diff --git a/dakota-dashboard/README.md b/dakota-dashboard/README.md
new file mode 100644
index 00000000..ae2a3757
--- /dev/null
+++ b/dakota-dashboard/README.md
@@ -0,0 +1,71 @@
+# dakota-dashboard
+
+Console for the [Dakota](https://docs.dakota.xyz) stablecoin on/off-ramp
+integration. Talks to `rust-backend/services/dakota-service` and
+`rust-backend/services/auth-service`.
+
+**Self-contained by design.** Nothing here is shared with `frontend/` — its own
+`package.json`, `node_modules`, `tsconfig.json` and Vercel project. The two apps
+happen to use the same tooling; they share no code.
+
+## One app, four audiences
+
+There is a single build. The JWT's `role` claim decides which routes render, and
+`dakota-service` enforces the same boundary server-side — the UI never filters
+data it was not already scoped out of.
+
+| Role | Sees | Reached by |
+|---|---|---|
+| `admin` | The whole platform: assets, rates, every customer, ramps, treasury, ops | Sui wallet on the `admin_addresses` allowlist |
+| `business` | Its own customers and their flows; can invite them | An invite minted by an admin |
+| `individual` | Only itself | An invite minted by an admin **or** by the business it belongs to |
+
+That last row is the point of the hierarchy: a partner business sends its own
+customers a signup link, and those customers land in a console scoped to
+themselves without us being involved.
+
+## Auth
+
+Username + password, or a Sui wallet, or both on one account. Settings → Security
+adds the second method in either direction; either then signs you in.
+
+No email is stored anywhere, so **there is no password reset** — recovery is an
+admin minting a fresh invite. Accounts are only created by redeeming an invite;
+the one exception is an allowlisted wallet, which bootstraps as an admin on
+first login.
+
+## Running locally
+
+```sh
+npm install
+cp .env.example .env # point at staging, or at local services
+npm run dev # http://localhost:5174
+```
+
+Port 5174 keeps it clear of the protocol frontend on 5173, and both dev origins
+are already in the services' CORS allow-lists.
+
+Against local services you also need `auth-service` and `dakota-service` running
+with their databases created — see `rust-backend/services/dakota-service/config/config.toml`.
+
+## Deploying
+
+Its own Vercel project rooted at this directory. `vercel.json` carries the SPA
+rewrite. Set `VITE_DAKOTA_API` and `VITE_AUTH_API`, and add the deployment origin
+to `allowed_origins` in both services' staging configs.
+
+**Staging only.** `dakota-service` integrates Dakota's *sandbox* and is
+deliberately absent from the prod compose file, so there is nothing for a
+production build of this app to talk to.
+
+## Sandbox limits worth knowing
+
+- **$2.00 per transaction.** Enforced in the forms and again server-side.
+- **Testnets only** — the sandbox lists mainnet network ids and then rejects them.
+- Banking is mocked, so onramps are funded with *Simulate a deposit* rather than a
+ real wire. Crypto legs settle for real on testnets.
+- A customer cannot open a ramp until Dakota approves them. In sandbox that is
+ the **Approve** button on the Customers screen (`kyb_approve`, which is the
+ transition that works for individuals too).
+- Nothing appears in Flows until the webhook target is registered — do it once
+ from Ops, and use **Resync** to backfill anything missed.
diff --git a/dakota-dashboard/index.html b/dakota-dashboard/index.html
new file mode 100644
index 00000000..187a35e6
--- /dev/null
+++ b/dakota-dashboard/index.html
@@ -0,0 +1,12 @@
+
+
+
+ );
+}
+
+/** Send an authenticated visitor to their own home rather than a 404. */
+function RoleHome() {
+ const { session } = useSession();
+ return ;
+}
+
+export default function App() {
+ const { session } = useSession();
+
+ return (
+
+ : }
+ />
+ } />
+
+ }>
+ {/* Admin: the whole platform. */}
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+ {/* Partner business: its own roster. Same components — the service
+ scopes the data off the token, so nothing here filters. */}
+ } />
+ } />
+ } />
+ } />
+
+ {/* Individual: itself. */}
+ } />
+ } />
+ } />
+
+ } />
+
+
+ } />
+
+ );
+}
diff --git a/dakota-dashboard/src/api/auth.ts b/dakota-dashboard/src/api/auth.ts
new file mode 100644
index 00000000..afc71602
--- /dev/null
+++ b/dakota-dashboard/src/api/auth.ts
@@ -0,0 +1,138 @@
+// Client for auth-service.
+//
+// One account can be reached by several login methods — a username+password
+// and a Sui wallet both resolve to the same `user_id`, and either can be added
+// to an account that started with the other. The JWT that comes back carries
+// `role` and `scope`, which is what every screen in this app keys off.
+
+import { AUTH_API } from "../config";
+
+export type Role = "admin" | "business" | "individual";
+
+export type Session = {
+ token: string;
+ user_id: string;
+ role: Role;
+ scope?: string;
+ address?: string;
+ expires_in: number;
+};
+
+export type Identity = {
+ id: string;
+ kind: "password" | "sui_wallet";
+ identifier: string;
+ created_at: string;
+ last_used_at?: string;
+};
+
+export type Me = {
+ user_id: string;
+ role: Role;
+ scope?: string;
+ identities: Identity[];
+};
+
+async function call(path: string, init?: RequestInit): Promise {
+ const res = await fetch(`${AUTH_API}${path}`, {
+ ...init,
+ headers: { "content-type": "application/json", ...(init?.headers ?? {}) },
+ });
+ if (!res.ok) {
+ // auth-service returns a bare string body on error; it is written to be
+ // shown to a person, so pass it straight through.
+ throw new Error((await res.text()) || `auth ${path} → ${res.status}`);
+ }
+ return res.status === 204 ? (undefined as T) : ((await res.json()) as T);
+}
+
+export const loginWithPassword = (username: string, password: string) =>
+ call("/login/password", {
+ method: "POST",
+ body: JSON.stringify({ username, password }),
+ });
+
+export const fetchChallenge = () =>
+ call<{ message: string }>("/challenge").then((r) => r.message);
+
+/** `signature` and `bytes` come straight from dapp-kit's signPersonalMessage. */
+export const loginWithWallet = (signature: string, bytes: string) =>
+ call("/login", {
+ method: "POST",
+ body: JSON.stringify({ signature, bytes }),
+ });
+
+export type RegisterMethod =
+ | { username: string; password: string }
+ | { signature: string; bytes: string };
+
+export const register = (invite: string, method: RegisterMethod) =>
+ call("/register", {
+ method: "POST",
+ body: JSON.stringify({ invite, ...method }),
+ });
+
+export const previewInvite = (invite: string) =>
+ call<{ role: Role; label?: string; valid: boolean; reason: string | null }>(
+ `/invites/preview?invite=${encodeURIComponent(invite)}`,
+ );
+
+export const me = (token: string) =>
+ call("/me", { headers: bearer(token) });
+
+export const addIdentity = (token: string, method: RegisterMethod) =>
+ call("/identities", {
+ method: "POST",
+ headers: bearer(token),
+ body: JSON.stringify(method),
+ });
+
+export const removeIdentity = (token: string, id: string) =>
+ call(`/identities/${id}`, { method: "DELETE", headers: bearer(token) });
+
+export const refresh = (token: string) =>
+ call("/refresh", { method: "POST", headers: bearer(token) });
+
+const bearer = (token: string) => ({ authorization: `Bearer ${token}` });
+
+// --- persistence -------------------------------------------------------------
+
+const KEY = "dakota-session";
+
+export function storeSession(s: Session) {
+ try {
+ localStorage.setItem(KEY, JSON.stringify(s));
+ } catch {
+ /* private browsing — the session just won't survive a reload */
+ }
+}
+
+export function loadSession(): Session | null {
+ try {
+ const raw = localStorage.getItem(KEY);
+ if (!raw) return null;
+ const s = JSON.parse(raw) as Session;
+ // A token past its expiry is worse than none: it produces confusing 401s
+ // on every screen instead of a clean redirect to login.
+ return jwtExp(s.token) > Date.now() / 1000 ? s : null;
+ } catch {
+ return null;
+ }
+}
+
+export function clearSession() {
+ try {
+ localStorage.removeItem(KEY);
+ } catch {
+ /* ignore */
+ }
+}
+
+export function jwtExp(token: string): number {
+ try {
+ const payload = token.split(".")[1].replace(/-/g, "+").replace(/_/g, "/");
+ return (JSON.parse(atob(payload)) as { exp?: number }).exp ?? 0;
+ } catch {
+ return 0;
+ }
+}
diff --git a/dakota-dashboard/src/api/dakota.ts b/dakota-dashboard/src/api/dakota.ts
new file mode 100644
index 00000000..21cf58d7
--- /dev/null
+++ b/dakota-dashboard/src/api/dakota.ts
@@ -0,0 +1,303 @@
+// Client for dakota-service.
+//
+// Every call carries the session JWT; the service reads `role` and `scope`
+// from it and scopes the answer server-side. Nothing here passes a customer
+// id as a claim of authority — the token is the authority, and asking about a
+// customer outside your scope returns 404.
+
+import { DAKOTA_API } from "../config";
+
+export type Asset = {
+ id: number;
+ symbol: string;
+ network_id: string;
+ onramp_enabled: boolean;
+ offramp_enabled: boolean;
+ swap_enabled: boolean;
+ sort_order: number;
+};
+
+export type Catalog = { assets: Asset[]; networks: string[] };
+
+export type Customer = {
+ dakota_customer_id: string;
+ customer_type: "business" | "individual";
+ is_sub_client: boolean;
+ sub_client_id: string | null;
+ external_ref: string | null;
+ application_id: string | null;
+ kyb_status: string | null;
+ kyc_status: string | null;
+ application_status: string | null;
+ created_at: string;
+ updated_at: string;
+};
+
+export type Account = {
+ dakota_account_id: string;
+ dakota_customer_id: string;
+ account_type: "onramp" | "offramp" | "swap";
+ source_asset: string | null;
+ source_network_id: string | null;
+ destination_asset: string | null;
+ destination_network_id: string | null;
+ rail: string | null;
+ created_at: string;
+};
+
+export type LedgerEvent = {
+ event_id: string;
+ event_type: string;
+ resource_id: string | null;
+ dakota_customer_id: string | null;
+ direction: string | null;
+ amount_minor: number | null;
+ asset: string | null;
+ exchange_rate: string | null;
+ fee_minor: number | null;
+ status: string | null;
+ occurred_at: string | null;
+};
+
+export type CustomerFlow = {
+ dakota_customer_id: string;
+ customer_type: string;
+ sub_client_id: string | null;
+ asset: string | null;
+ events: number;
+ inbound_minor: number | null;
+ outbound_minor: number | null;
+};
+
+export type AssetTotal = {
+ asset: string;
+ inbound_minor: number;
+ outbound_minor: number;
+ events: number;
+};
+
+export type Flows = { by_customer: CustomerFlow[]; totals: AssetTotal[] };
+
+export type FeeSchedule = {
+ id: number;
+ source: string;
+ transfer_fee_bps: number | null;
+ ach_fee_cents: number | null;
+ wire_fee_cents: number | null;
+ sepa_fee_cents: number | null;
+ swift_fee_cents: number | null;
+ kyc_fee_cents: number | null;
+ kyb_fee_cents: number | null;
+ effective_from: string;
+ note: string | null;
+};
+
+export type Rates = {
+ schedule: FeeSchedule | null;
+ realised: Array<{
+ asset: string | null;
+ exchange_rate: string | null;
+ fee_minor: number | null;
+ amount_minor: number | null;
+ occurred_at: string | null;
+ }>;
+};
+
+export type Invite = { invite_id: string; role: string; expires_at: string };
+
+export class ApiError extends Error {
+ constructor(
+ message: string,
+ readonly status: number,
+ readonly dakotaRequestId?: string,
+ readonly fields?: Array<{ field?: string; message?: string }>,
+ ) {
+ super(message);
+ }
+}
+
+async function call(token: string, path: string, init?: RequestInit): Promise {
+ const res = await fetch(`${DAKOTA_API}${path}`, {
+ ...init,
+ headers: {
+ "content-type": "application/json",
+ authorization: `Bearer ${token}`,
+ ...(init?.headers ?? {}),
+ },
+ });
+ const text = await res.text();
+ if (!res.ok) {
+ // dakota-service relays Dakota's RFC 9457 detail verbatim, because those
+ // messages are specific and actionable ("capabilities are required",
+ // "amount 5 exceeds sandbox cap of 2").
+ try {
+ const body = JSON.parse(text) as {
+ error?: string;
+ dakota_request_id?: string;
+ fields?: Array<{ field?: string; message?: string }>;
+ };
+ throw new ApiError(
+ body.error ?? text ?? `request failed (${res.status})`,
+ res.status,
+ body.dakota_request_id,
+ body.fields,
+ );
+ } catch (e) {
+ if (e instanceof ApiError) throw e;
+ throw new ApiError(text || `request failed (${res.status})`, res.status);
+ }
+ }
+ return text ? (JSON.parse(text) as T) : (undefined as T);
+}
+
+export const getCatalog = (t: string) => call(t, "/catalog");
+export const getRates = (t: string) => call(t, "/rates");
+export const listCustomers = (t: string) => call(t, "/customers");
+export const listAccounts = (t: string) => call(t, "/accounts");
+export const getFlows = (t: string) => call(t, "/flows");
+export const getFeed = (t: string, limit = 100) =>
+ call(t, `/flows/feed?limit=${limit}`);
+export const getCustomerFeed = (t: string, id: string) =>
+ call(t, `/flows/${id}`);
+
+/** Dakota's live record, including the name we never store ourselves. */
+export const getCustomer = (t: string, id: string) =>
+ call>(t, `/customers/${id}`);
+
+export const getCapabilities = (t: string, id: string) =>
+ call>(t, `/customers/${id}/capabilities`);
+
+export type CreateCustomerBody = {
+ name: string;
+ customer_type: "business" | "individual";
+ external_ref?: string;
+ is_sub_client?: boolean;
+ sub_client_id?: string;
+ with_invite?: boolean;
+};
+
+export type CreateCustomerResult = {
+ customer: Customer;
+ application_url: string;
+ invite?: Invite;
+};
+
+export const createCustomer = (t: string, body: CreateCustomerBody) =>
+ call(t, "/customers", {
+ method: "POST",
+ body: JSON.stringify(body),
+ });
+
+export const createInvite = (t: string, customerId: string) =>
+ call(t, `/customers/${customerId}/invite`, { method: "POST" });
+
+export const createRecipient = (
+ t: string,
+ customerId: string,
+ body: { name: string; address?: unknown },
+) =>
+ call<{ id: string }>(t, `/customers/${customerId}/recipients`, {
+ method: "POST",
+ body: JSON.stringify(body),
+ });
+
+export const createDestination = (
+ t: string,
+ recipientId: string,
+ body: Record,
+) =>
+ call<{ id: string }>(t, `/recipients/${recipientId}/destinations`, {
+ method: "POST",
+ body: JSON.stringify(body),
+ });
+
+export type CreateAccountBody = {
+ customer_id: string;
+ account_type: "onramp" | "offramp" | "swap";
+ crypto_destination_id?: string;
+ fiat_destination_id?: string;
+ source_asset?: string;
+ destination_asset?: string;
+ source_network_id?: string;
+ destination_network_id?: string;
+};
+
+/** Returns Dakota's raw account body — deposit details live in there. */
+export const createAccount = (t: string, body: CreateAccountBody) =>
+ call>(t, "/accounts", {
+ method: "POST",
+ body: JSON.stringify(body),
+ });
+
+export const getAccount = (t: string, id: string) =>
+ call>(t, `/accounts/${id}`);
+
+// --- admin -------------------------------------------------------------------
+
+export const upsertAsset = (t: string, a: Omit) =>
+ call(t, "/admin/assets", { method: "PUT", body: JSON.stringify(a) });
+
+export const deleteAsset = (t: string, id: number) =>
+ call(t, `/admin/assets/${id}`, { method: "DELETE" });
+
+export const setRates = (t: string, body: Partial & { note?: string }) =>
+ call(t, "/admin/rates", { method: "POST", body: JSON.stringify(body) });
+
+export const listSubClients = (t: string) =>
+ call<{ sub_clients: Customer[]; summary: any }>(t, "/admin/sub-clients");
+
+export const simulateOnboarding = (t: string, customerId: string, type?: string) =>
+ call<{ previous_state?: string; new_state?: string }>(t, "/admin/sandbox/onboarding", {
+ method: "POST",
+ body: JSON.stringify({ customer_id: customerId, type }),
+ });
+
+export type SimulateInboundBody = {
+ type: string;
+ amount: string;
+ currency?: string;
+ account_id?: string;
+ wallet_address?: string;
+};
+
+export const simulateInbound = (t: string, body: SimulateInboundBody) =>
+ call>(t, "/admin/sandbox/inbound", {
+ method: "POST",
+ body: JSON.stringify(body),
+ });
+
+export const resync = (t: string) =>
+ call<{ scanned: number; inserted: number }>(t, "/admin/resync", { method: "POST" });
+
+export const registerWebhook = (t: string) =>
+ call<{ url: string }>(t, "/admin/webhooks/register", { method: "POST" });
+
+export const listWebhooks = (t: string) => call(t, "/admin/webhooks");
+
+export const getTreasury = (t: string) => call<{ treasury: any[] }>(t, "/admin/treasury");
+
+export const setupTreasury = (t: string, label = "treasury", family = "evm") =>
+ call(t, "/admin/treasury/setup", {
+ method: "POST",
+ body: JSON.stringify({ label, family }),
+ });
+
+export const treasurySend = (
+ t: string,
+ walletId: string,
+ body: { to: string; amount: string; asset_id: string; network_id: string },
+) =>
+ call>(t, `/admin/treasury/${walletId}/send`, {
+ method: "POST",
+ body: JSON.stringify(body),
+ });
+
+// --- formatting --------------------------------------------------------------
+
+/** Minor units (cents) → a display string. Amounts are integers end to end. */
+export function formatMinor(minor: number | null | undefined): string {
+ if (minor == null) return "—";
+ const sign = minor < 0 ? "-" : "";
+ const abs = Math.abs(minor);
+ return `${sign}${Math.floor(abs / 100)}.${String(abs % 100).padStart(2, "0")}`;
+}
diff --git a/dakota-dashboard/src/components/ActivityTable.tsx b/dakota-dashboard/src/components/ActivityTable.tsx
new file mode 100644
index 00000000..fcf01f28
--- /dev/null
+++ b/dakota-dashboard/src/components/ActivityTable.tsx
@@ -0,0 +1,125 @@
+import type { AssetTotal, CustomerFlow, LedgerEvent } from "../api/dakota";
+import { formatMinor } from "../api/dakota";
+import { Empty, Panel, StatusPill, Table, fmtTime, shortId } from "./ui";
+
+/** Platform- or roster-wide totals per asset. */
+export function TotalsPanel({ totals }: { totals: AssetTotal[] }) {
+ return (
+
+ {totals.length === 0 ? (
+ No settled activity yet.
+ ) : (
+
+
Asset
+
In
+
Out
+
Net
+
Events
+
+ }
+ >
+ {totals.map((t) => (
+
+
{t.asset}
+
{formatMinor(t.inbound_minor)}
+
{formatMinor(t.outbound_minor)}
+
{formatMinor(t.inbound_minor - t.outbound_minor)}
+
{t.events}
+
+ ))}
+
+ )}
+
+ );
+}
+
+export function FlowsTable({
+ flows,
+ onSelect,
+}: {
+ flows: CustomerFlow[];
+ onSelect?: (customerId: string) => void;
+}) {
+ // The LEFT JOIN emits a null-asset row for a customer with no activity;
+ // showing it as a blank line is more honest than dropping the customer.
+ return (
+
+ {flows.length === 0 ? (
+ No customers yet.
+ ) : (
+
+ No assets are enabled for {flow}.{" "}
+ {isAdmin ? "Enable one under Assets." : "Ask an admin to enable one."}
+
+ )}
+
+
+
+
+
+
+ {!approved && customer && (
+
+ This customer is not approved yet (kyb_status ={" "}
+ {customer.kyb_status ?? "unknown"}). Dakota will refuse the account until it
+ is. {isAdmin ? "Use Approve on the Customers screen." : ""}
+
+ )}
+
+
+
+ Sandbox caps each transfer at ${SANDBOX_MAX_AMOUNT.toFixed(2)}.
+
+ >
+ )}
+
+
+ {result && }
+ >
+ );
+}
+
+/** Where the money actually has to go.
+ *
+ * These values come straight from Dakota and are never stored by us — the
+ * bank block in particular is pure PII. */
+function DepositInstructions({ result, flow }: { result: Record; flow: Flow }) {
+ const bank = result.bank_account as Record | undefined;
+ return (
+
+
+
+ {flow === "onramp" && bank ? (
+ <>
+
+
+
+
+
+
+
+
+
+ Wire USD to these details. Dakota converts and delivers the stablecoin on-chain.
+
+ On {String(result.source_network_id ?? "")}. Sending on any other
+ chain loses the funds.
+
+ >
+ ) : (
+
Dakota returned no deposit details for this account.
+ )}
+
+ );
+}
diff --git a/dakota-dashboard/src/components/ui.tsx b/dakota-dashboard/src/components/ui.tsx
new file mode 100644
index 00000000..4573d686
--- /dev/null
+++ b/dakota-dashboard/src/components/ui.tsx
@@ -0,0 +1,110 @@
+import { useState } from "react";
+import type { ReactNode } from "react";
+
+import { ApiError } from "../api/dakota";
+
+export function Panel({ title, hint, children }: { title?: string; hint?: string; children: ReactNode }) {
+ return (
+
+ {title &&
{title}
}
+ {hint &&
{hint}
}
+ {children}
+
+ );
+}
+
+/** Renders an error the way the service meant it to be read.
+ *
+ * dakota-service relays Dakota's RFC 9457 `detail` verbatim because those
+ * messages name the actual problem; the request id is worth showing because
+ * it is the first thing Dakota support asks for. */
+export function ErrorBox({ error }: { error: unknown }) {
+ if (!error) return null;
+ const msg = error instanceof Error ? error.message : String(error);
+ const api = error instanceof ApiError ? error : null;
+ return (
+
+
{msg}
+ {api?.fields?.length ? (
+
+ {api.fields.map((f, i) => (
+
+ {f.field ? {f.field} : null} {f.message}
+
+ ))}
+
+ ) : null}
+ {api?.dakotaRequestId ? (
+
+ dakota request id: {api.dakotaRequestId}
+
+ ) : null}
+
+ );
+}
+
+export function StatusPill({ status }: { status: string | null | undefined }) {
+ if (!status) return unknown;
+ const s = status.toLowerCase();
+ const tone =
+ s === "active" || s === "approved" || s === "completed" || s === "settled"
+ ? "ok"
+ : s === "rejected" || s === "failed" || s === "frozen"
+ ? "err"
+ : s === "pending" || s === "processing" || s === "not_started"
+ ? "warn"
+ : "";
+ return {status};
+}
+
+/** A value the user needs to hand to someone else — an invite link, a deposit
+ * address, a set of wire details. Copying is the whole point, so it is one
+ * click and confirms itself. */
+export function CopyField({ label, value }: { label: string; value: string }) {
+ const [copied, setCopied] = useState(false);
+ return (
+
+ );
+}
+
+export function Empty({ children }: { children: ReactNode }) {
+ return
{children}
;
+}
+
+export function Table({ head, children }: { head: ReactNode; children: ReactNode }) {
+ return (
+
+
+ {head}
+ {children}
+
+
+ );
+}
+
+/** Short form of a KSUID, which is 27 characters of noise in a table cell. */
+export const shortId = (id: string | null | undefined) =>
+ !id ? "—" : id.length <= 12 ? id : `${id.slice(0, 6)}…${id.slice(-4)}`;
+
+export const fmtTime = (t: string | null | undefined) =>
+ !t ? "—" : new Date(t).toLocaleString();
diff --git a/dakota-dashboard/src/config.ts b/dakota-dashboard/src/config.ts
new file mode 100644
index 00000000..a9864be5
--- /dev/null
+++ b/dakota-dashboard/src/config.ts
@@ -0,0 +1,16 @@
+// Service endpoints. Defaults point at staging, which is the only environment
+// this dashboard is ever deployed against — dakota-service talks to Dakota's
+// SANDBOX and is deliberately absent from the prod compose file.
+
+export const DAKOTA_API = (
+ import.meta.env.VITE_DAKOTA_API ?? "https://sui-options.com/staging/dakota"
+).replace(/\/$/, "");
+
+export const AUTH_API = (
+ import.meta.env.VITE_AUTH_API ?? "https://sui-options.com/staging/auth"
+).replace(/\/$/, "");
+
+/// Dakota's sandbox refuses anything above $2.00 per transaction. Surfaced in
+/// the UI so the limit is visible before a form is submitted rather than
+/// arriving as a rejection afterwards.
+export const SANDBOX_MAX_AMOUNT = 2.0;
diff --git a/dakota-dashboard/src/main.tsx b/dakota-dashboard/src/main.tsx
new file mode 100644
index 00000000..5e5d0574
--- /dev/null
+++ b/dakota-dashboard/src/main.tsx
@@ -0,0 +1,46 @@
+import React from "react";
+import ReactDOM from "react-dom/client";
+import { BrowserRouter } from "react-router-dom";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { SuiClientProvider, WalletProvider, createNetworkConfig } from "@mysten/dapp-kit";
+import { getJsonRpcFullnodeUrl } from "@mysten/sui/jsonRpc";
+
+import "@mysten/dapp-kit/dist/index.css";
+import "./styles.css";
+import App from "./App";
+import { SessionProvider } from "./state/session";
+
+const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ // Sandbox data changes when a human clicks something, not continuously,
+ // so refetching on every window focus is noise.
+ refetchOnWindowFocus: false,
+ staleTime: 10_000,
+ retry: 1,
+ },
+ },
+});
+
+// Sui is only ever used to prove wallet ownership at login — this app builds
+// no transactions, so one network is enough regardless of which chain the
+// ramps settle on.
+const { networkConfig } = createNetworkConfig({
+ testnet: { network: "testnet", url: getJsonRpcFullnodeUrl("testnet") },
+});
+
+ReactDOM.createRoot(document.getElementById("root")!).render(
+
+
+
+
+
+
+
+
+
+
+
+
+ ,
+);
diff --git a/dakota-dashboard/src/screens/Assets.tsx b/dakota-dashboard/src/screens/Assets.tsx
new file mode 100644
index 00000000..a8937a5b
--- /dev/null
+++ b/dakota-dashboard/src/screens/Assets.tsx
@@ -0,0 +1,175 @@
+import { useState } from "react";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+
+import * as api from "../api/dakota";
+import { Empty, ErrorBox, Panel, Table } from "../components/ui";
+import { useAuthed } from "../state/session";
+
+/** The supported-asset catalog and the rate card.
+ *
+ * Dakota has no assets endpoint and no fee endpoint available to our client
+ * tier, so both of these are ours: the catalog drives every dropdown in the
+ * app and doubles as the server-side allow-list, and the schedule is what we
+ * *expect* to be charged. What we were *actually* charged comes from
+ * transaction receipts and is shown beside it. */
+export default function Assets() {
+ const { token } = useAuthed();
+ const qc = useQueryClient();
+ const catalog = useQuery({ queryKey: ["catalog"], queryFn: () => api.getCatalog(token) });
+ const rates = useQuery({ queryKey: ["rates"], queryFn: () => api.getRates(token) });
+
+ const [symbol, setSymbol] = useState("USDC");
+ const [network, setNetwork] = useState("");
+ const [flows, setFlows] = useState({ onramp: true, offramp: true, swap: true });
+ const [error, setError] = useState(null);
+
+ const save = async () => {
+ setError(null);
+ try {
+ await api.upsertAsset(token, {
+ symbol: symbol.trim().toUpperCase(),
+ network_id: network,
+ onramp_enabled: flows.onramp,
+ offramp_enabled: flows.offramp,
+ swap_enabled: flows.swap,
+ sort_order: 0,
+ });
+ await qc.invalidateQueries({ queryKey: ["catalog"] });
+ } catch (e) {
+ setError(e);
+ }
+ };
+
+ const remove = async (id: number) => {
+ setError(null);
+ try {
+ await api.deleteAsset(token, id);
+ await qc.invalidateQueries({ queryKey: ["catalog"] });
+ } catch (e) {
+ setError(e);
+ }
+ };
+
+ return (
+ <>
+
+ ) : (
+ No customers yet.
+ )}
+
+ >
+ );
+}
diff --git a/dakota-dashboard/src/screens/Flows.tsx b/dakota-dashboard/src/screens/Flows.tsx
new file mode 100644
index 00000000..366f06a6
--- /dev/null
+++ b/dakota-dashboard/src/screens/Flows.tsx
@@ -0,0 +1,27 @@
+import { useQuery } from "@tanstack/react-query";
+
+import * as api from "../api/dakota";
+import { EventFeed, FlowsTable, TotalsPanel } from "../components/ActivityTable";
+import { ErrorBox } from "../components/ui";
+import { useAuthed } from "../state/session";
+
+/** Activity and amount flows.
+ *
+ * Identical for every role — the service decides what "everything" means from
+ * the token, so an admin sees the platform, a business sees its roster and an
+ * individual sees itself, all from the same two calls. */
+export default function Flows({ title = "Flows" }: { title?: string }) {
+ const { token } = useAuthed();
+ const flows = useQuery({ queryKey: ["flows"], queryFn: () => api.getFlows(token) });
+ const feed = useQuery({ queryKey: ["feed"], queryFn: () => api.getFeed(token) });
+
+ return (
+ <>
+
{title}
+
+
+
+
+ >
+ );
+}
diff --git a/dakota-dashboard/src/screens/Login.tsx b/dakota-dashboard/src/screens/Login.tsx
new file mode 100644
index 00000000..f4a4c0b7
--- /dev/null
+++ b/dakota-dashboard/src/screens/Login.tsx
@@ -0,0 +1,119 @@
+import { useState } from "react";
+import { useNavigate } from "react-router-dom";
+import { ConnectButton, useCurrentAccount, useSignPersonalMessage } from "@mysten/dapp-kit";
+
+import * as auth from "../api/auth";
+import { ErrorBox, Panel } from "../components/ui";
+import { homeFor, useSession } from "../state/session";
+
+/** Sign the server's challenge and exchange it for a session.
+ *
+ * Shared with the settings screen, which uses the identical proof to *attach*
+ * a wallet to an existing account. */
+export function useWalletProof() {
+ const account = useCurrentAccount();
+ const { mutateAsync: signPersonalMessage } = useSignPersonalMessage();
+
+ return async () => {
+ if (!account) throw new Error("connect a wallet first");
+ const message = await auth.fetchChallenge();
+ const bytes = new TextEncoder().encode(message);
+ const res = await signPersonalMessage({ message: bytes });
+ // dapp-kit returns both already base64-encoded, which is what the service
+ // expects — re-encoding here would corrupt them.
+ return { signature: res.signature, bytes: res.bytes };
+ };
+}
+
+export default function Login() {
+ const { setSession } = useSession();
+ const navigate = useNavigate();
+ const account = useCurrentAccount();
+ const proveWallet = useWalletProof();
+
+ const [username, setUsername] = useState("");
+ const [password, setPassword] = useState("");
+ const [error, setError] = useState(null);
+ const [busy, setBusy] = useState(false);
+
+ const finish = (s: auth.Session) => {
+ setSession(s);
+ navigate(homeFor(s.role), { replace: true });
+ };
+
+ const run = async (fn: () => Promise) => {
+ setBusy(true);
+ setError(null);
+ try {
+ finish(await fn());
+ } catch (e) {
+ setError(e);
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ return (
+
+
+
Dakota Console
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ No account? You need an invite link. Ask whoever runs this console —
+ there is no self-serve signup, and no password reset, because we store
+ no email addresses.
+
Sandbox caps transfers at ${SANDBOX_MAX_AMOUNT.toFixed(2)}.
+ )}
+
+
+ Signed server-side; the key never reaches this browser.
+
+
+ );
+}
diff --git a/dakota-dashboard/src/state/session.tsx b/dakota-dashboard/src/state/session.tsx
new file mode 100644
index 00000000..384f21bc
--- /dev/null
+++ b/dakota-dashboard/src/state/session.tsx
@@ -0,0 +1,63 @@
+import { createContext, useContext, useEffect, useMemo, useState } from "react";
+import type { ReactNode } from "react";
+
+import * as auth from "../api/auth";
+import type { Role, Session } from "../api/auth";
+
+type Ctx = {
+ session: Session | null;
+ setSession: (s: Session | null) => void;
+ logout: () => void;
+};
+
+const SessionContext = createContext({
+ session: null,
+ setSession: () => {},
+ logout: () => {},
+});
+
+export function SessionProvider({ children }: { children: ReactNode }) {
+ const [session, setRaw] = useState(() => auth.loadSession());
+
+ const setSession = (s: Session | null) => {
+ if (s) auth.storeSession(s);
+ else auth.clearSession();
+ setRaw(s);
+ };
+
+ // Slide the token forward well before it expires. The window is bounded
+ // server-side by refresh_max_secs, so this extends a session in use without
+ // making one immortal.
+ useEffect(() => {
+ if (!session) return;
+ const secondsLeft = auth.jwtExp(session.token) - Date.now() / 1000;
+ const delay = Math.max(30, secondsLeft - 300) * 1000;
+ const timer = setTimeout(() => {
+ auth
+ .refresh(session.token)
+ .then(setSession)
+ // A failed refresh means the window closed or the IP changed; drop to
+ // the login screen rather than looping on 401s.
+ .catch(() => setSession(null));
+ }, delay);
+ return () => clearTimeout(timer);
+ }, [session]);
+
+ const value = useMemo(
+ () => ({ session, setSession, logout: () => setSession(null) }),
+ [session],
+ );
+ return {children};
+}
+
+export const useSession = () => useContext(SessionContext);
+
+/** Session that is known to exist — for use inside authenticated routes. */
+export function useAuthed(): Session {
+ const { session } = useSession();
+ if (!session) throw new Error("useAuthed outside an authenticated route");
+ return session;
+}
+
+export const homeFor = (role: Role) =>
+ role === "admin" ? "/admin" : role === "business" ? "/business" : "/customer";
diff --git a/dakota-dashboard/src/styles.css b/dakota-dashboard/src/styles.css
new file mode 100644
index 00000000..5b739057
--- /dev/null
+++ b/dakota-dashboard/src/styles.css
@@ -0,0 +1,150 @@
+:root {
+ --bg: #0e1116;
+ --panel: #161b22;
+ --panel-2: #1c232c;
+ --border: #2b3440;
+ --text: #e6edf3;
+ --muted: #8b949e;
+ --accent: #4493f8;
+ --ok: #3fb950;
+ --warn: #d29922;
+ --err: #f85149;
+ --radius: 8px;
+}
+
+@media (prefers-color-scheme: light) {
+ :root {
+ --bg: #f6f8fa;
+ --panel: #ffffff;
+ --panel-2: #f0f3f6;
+ --border: #d0d7de;
+ --text: #1f2328;
+ --muted: #636c76;
+ }
+}
+
+* { box-sizing: border-box; }
+
+body {
+ margin: 0;
+ background: var(--bg);
+ color: var(--text);
+ font: 14px/1.5 ui-sans-serif, -apple-system, "Segoe UI", system-ui, sans-serif;
+}
+
+code, .mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
+
+a { color: var(--accent); }
+
+.app { display: flex; min-height: 100vh; }
+
+.sidebar {
+ width: 220px;
+ flex: 0 0 220px;
+ border-right: 1px solid var(--border);
+ background: var(--panel);
+ padding: 16px 12px;
+}
+.sidebar h1 { font-size: 15px; margin: 0 0 4px 8px; }
+.sidebar .role { font-size: 11px; color: var(--muted); margin: 0 0 16px 8px; text-transform: uppercase; letter-spacing: .06em; }
+.sidebar nav a {
+ display: block;
+ padding: 7px 8px;
+ border-radius: 6px;
+ color: var(--text);
+ text-decoration: none;
+}
+.sidebar nav a:hover { background: var(--panel-2); }
+.sidebar nav a.active { background: var(--accent); color: #fff; }
+
+.main { flex: 1; padding: 24px 28px; max-width: 1100px; min-width: 0; }
+.main > h2 { margin-top: 0; }
+
+.panel {
+ background: var(--panel);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 16px;
+ margin-bottom: 16px;
+}
+.panel h3 { margin: 0 0 12px; font-size: 14px; }
+.panel .hint { color: var(--muted); font-size: 12px; margin: -6px 0 12px; }
+
+label { display: block; margin-bottom: 10px; }
+label span { display: block; font-size: 12px; color: var(--muted); margin-bottom: 4px; }
+
+input, select, button, textarea {
+ font: inherit;
+ color: var(--text);
+ background: var(--panel-2);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ padding: 7px 9px;
+ width: 100%;
+}
+button {
+ background: var(--accent);
+ border-color: transparent;
+ color: #fff;
+ cursor: pointer;
+ width: auto;
+ padding: 8px 14px;
+}
+button.secondary { background: var(--panel-2); color: var(--text); border-color: var(--border); }
+button:disabled { opacity: .5; cursor: not-allowed; }
+
+.row { display: flex; gap: 12px; flex-wrap: wrap; }
+.row > * { flex: 1 1 180px; }
+.actions { display: flex; gap: 8px; align-items: center; margin-top: 4px; }
+
+/* Wide content scrolls inside its own box; the page never scrolls sideways. */
+.scroll-x { overflow-x: auto; }
+
+table { border-collapse: collapse; width: 100%; font-size: 13px; }
+th, td { text-align: left; padding: 7px 10px; border-bottom: 1px solid var(--border); white-space: nowrap; }
+th { color: var(--muted); font-weight: 500; font-size: 11px; text-transform: uppercase; letter-spacing: .04em; }
+td.num, th.num { text-align: right; font-family: ui-monospace, monospace; }
+
+.pill { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 11px; border: 1px solid var(--border); }
+.pill.ok { color: var(--ok); border-color: var(--ok); }
+.pill.warn { color: var(--warn); border-color: var(--warn); }
+.pill.err { color: var(--err); border-color: var(--err); }
+
+.error {
+ background: color-mix(in srgb, var(--err) 12%, transparent);
+ border: 1px solid var(--err);
+ color: var(--err);
+ border-radius: 6px;
+ padding: 10px 12px;
+ margin-bottom: 12px;
+ white-space: pre-wrap;
+}
+.success {
+ background: color-mix(in srgb, var(--ok) 12%, transparent);
+ border: 1px solid var(--ok);
+ border-radius: 6px;
+ padding: 10px 12px;
+ margin-bottom: 12px;
+}
+.muted { color: var(--muted); }
+.empty { color: var(--muted); padding: 20px; text-align: center; }
+
+.centered { display: flex; align-items: center; justify-content: center; min-height: 100vh; padding: 20px; }
+.card { width: 100%; max-width: 380px; }
+
+.copy-row { display: flex; gap: 6px; align-items: center; }
+.copy-row input { font-family: ui-monospace, monospace; font-size: 11px; }
+
+.tabs { display: flex; gap: 4px; margin-bottom: 14px; border-bottom: 1px solid var(--border); }
+.tabs button {
+ background: none; color: var(--muted); border: none; border-bottom: 2px solid transparent;
+ border-radius: 0; padding: 8px 12px;
+}
+.tabs button.active { color: var(--text); border-bottom-color: var(--accent); }
+
+@media (max-width: 720px) {
+ .app { flex-direction: column; }
+ .sidebar { width: auto; flex: none; border-right: none; border-bottom: 1px solid var(--border); }
+ .sidebar nav { display: flex; flex-wrap: wrap; gap: 4px; }
+ .main { padding: 16px; }
+}
diff --git a/dakota-dashboard/src/vite-env.d.ts b/dakota-dashboard/src/vite-env.d.ts
new file mode 100644
index 00000000..45781edc
--- /dev/null
+++ b/dakota-dashboard/src/vite-env.d.ts
@@ -0,0 +1,10 @@
+///
+
+interface ImportMetaEnv {
+ readonly VITE_DAKOTA_API?: string;
+ readonly VITE_AUTH_API?: string;
+}
+
+interface ImportMeta {
+ readonly env: ImportMetaEnv;
+}
diff --git a/dakota-dashboard/tsconfig.json b/dakota-dashboard/tsconfig.json
new file mode 100644
index 00000000..a4c834a6
--- /dev/null
+++ b/dakota-dashboard/tsconfig.json
@@ -0,0 +1,20 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["src"]
+}
diff --git a/dakota-dashboard/vercel.json b/dakota-dashboard/vercel.json
new file mode 100644
index 00000000..0f32683a
--- /dev/null
+++ b/dakota-dashboard/vercel.json
@@ -0,0 +1,3 @@
+{
+ "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }]
+}
diff --git a/dakota-dashboard/vite.config.ts b/dakota-dashboard/vite.config.ts
new file mode 100644
index 00000000..a02ae66f
--- /dev/null
+++ b/dakota-dashboard/vite.config.ts
@@ -0,0 +1,10 @@
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react";
+
+// 5174 keeps this clear of the protocol frontend on 5173, so both can run at
+// once — and both dev ports are in dakota-service's and auth-service's CORS
+// allow-lists.
+export default defineConfig({
+ plugins: [react()],
+ server: { port: 5174 },
+});
diff --git a/docs/dakota-rollout.md b/docs/dakota-rollout.md
new file mode 100644
index 00000000..132b5668
--- /dev/null
+++ b/docs/dakota-rollout.md
@@ -0,0 +1,247 @@
+# Dakota integration — rollout
+
+What an operator has to do by hand before and after this ships. The code cannot
+do any of it: databases, secrets and ECR repos are provisioned out of band, and
+`deploy.sh` health-gates every service it plans.
+
+Behaviour verified against the live sandbox lives in
+[dakota-sandbox-notes.md](dakota-sandbox-notes.md). This file is only the
+runbook.
+
+---
+
+## 1. Blocking: `auth_prod` must exist before the next prod deploy
+
+**auth-service gained a hard Postgres dependency.** It became a multi-method
+identity service (username+password *or* Sui wallet, linkable to one account),
+and the store is Postgres. It will not boot without it.
+
+auth-service ships to **prod**, is health-gated, and `deploy.sh` rolls back the
+**whole planned set** on the first failed gate. So a prod deploy without this
+database does not just fail auth-service — it reverts everything deployed
+alongside it.
+
+The embedded migrations run themselves on boot. The database and role do not.
+
+```sql
+-- prod RDS
+CREATE ROLE auth_prod LOGIN PASSWORD '';
+CREATE DATABASE auth_prod OWNER auth_prod;
+```
+
+The Dakota work this came from is staging-only, but auth-service is shared, so
+prod carries the dependency regardless.
+
+## 2. The other two databases
+
+```sql
+-- staging RDS
+CREATE ROLE auth_staging LOGIN PASSWORD '';
+CREATE DATABASE auth_staging OWNER auth_staging;
+CREATE ROLE dakota_staging LOGIN PASSWORD '';
+CREATE DATABASE dakota_staging OWNER dakota_staging;
+```
+
+## 3. Secrets Manager
+
+Create `options/staging/dakota-service`. `render-secrets.sh` writes it to
+`/run/secrets/dakota-service.toml`; it **silently skips an absent secret**, and
+the container then crash-loops on the missing file.
+
+```toml
+[dakota]
+# From platform.sandbox.dakota.xyz. Shown once.
+api_key = "..."
+
+# Optional — only the treasury needs it. Everything else works without it.
+# openssl ecparam -name prime256v1 -genkey -noout -out p256.pem
+# openssl pkcs8 -topk8 -nocrypt -in p256.pem
+wallet_p256_pem = """
+-----BEGIN PRIVATE KEY-----
+...
+-----END PRIVATE KEY-----
+"""
+```
+
+There is **no `options/prod/dakota-service`**, and there should not be: the
+service is not declared in the prod compose file.
+
+## 4. ECR repo
+
+`infra/ecr.tf` gained `dakota-service`. Apply before the first image push — a
+missing repo fails the push with a 403, not a useful error.
+
+```sh
+cd rust-backend/infra && terraform plan && terraform apply
+```
+
+## 5. Deploy, then register the webhook
+
+Deploy staging. Then, **once**, from the dashboard's Ops screen (or
+`POST /staging/dakota/admin/webhooks/register` with an admin token):
+
+Nothing appears in the activity feed until a target is registered. Registration
+is deliberately manual rather than at boot — registering on every restart churns
+targets, and the URL depends on how the environment is proxied.
+
+If events were missed (target registered late, downtime past Dakota's 48-hour
+retry window), **Resync** replays `GET /events` through the same extractor.
+Events are keyed by id, so replaying cannot double-count. It reports
+`truncated` when Dakota had more than one page — run it again rather than
+assuming a partial backfill was complete.
+
+## 6. Dashboard
+
+New Vercel project rooted at `dakota-dashboard/`. `vercel.json` carries the SPA
+rewrite.
+
+```
+VITE_DAKOTA_API = https://sui-options.com/staging/dakota
+VITE_AUTH_API = https://sui-options.com/staging/auth
+```
+
+Then add the deployment origin to `allowed_origins` in
+`services/dakota-service/config/config.staging.toml` and
+`services/auth-service/config/config.staging.toml`.
+
+## 7. First admin
+
+There is no self-serve signup. The first admin bootstraps from the
+`admin_addresses` allowlist in auth-service's config: an allowlisted Sui wallet
+is auto-provisioned as an admin on first login. That is the **only**
+account-creation path that skips an invite — treat the list as a root-of-trust.
+
+Everyone else arrives through an invite:
+
+```
+admin → creates a partner business → copies its signup link
+ → business registers → invites its own customers
+admin → creates an individual directly → copies its signup link
+```
+
+Password recovery does not exist, because no email is stored. Recovery is an
+admin minting a fresh invite.
+
+---
+
+## Staging-only, and how that is enforced
+
+`deploy.sh` filters the requested set against `docker compose config --services`
+for the target environment. A service absent from that file can never be planned
+or health-gated. That is the same mechanism excluding `cctp-relay`, `market-sim`,
+`twitter-service` and `social-bot` from prod.
+
+For `dakota-service` this is by design rather than circumstance — it integrates
+Dakota's **sandbox** (testnet custody, mocked banking, a $2 per-transaction cap),
+so there is nothing useful it could do in prod. Four things keep it out, and all
+four have to be undone deliberately:
+
+| | |
+|---|---|
+| `docker-compose.prod.yml` | not declared (with a comment saying why) |
+| `nginx.prod.conf` | no route |
+| `config.prod.toml` | does not exist — the image would exit on the missing file |
+| `options/prod/dakota-service` | no secret |
+
+Verify after any deploy change:
+
+```sh
+python3 deployment/test_affected.py # 20 tests
+python3 deployment/affected.py rust-backend/services/dakota-service/src/main.rs
+# → ["dakota-service"]
+grep -c '^ dakota-service:' deployment/compose/docker-compose.prod.yml # → 0
+```
+
+---
+
+## A security change that came with this
+
+auth-service now issues tokens to **business** and **individual** roles, not
+only admins. `token-info`'s mutate routes were gated on `require_auth`, which
+only proves a token is *valid* — so any newly-created customer account would
+have been able to mutate the token catalog.
+
+`crates/auth-client` gained `require_admin`, and `token-info` uses it. Anything
+else that gates a privileged operation on `require_auth` wants the same
+treatment.
+
+---
+
+## Verifying it works
+
+```sh
+# unit + integration
+cargo test -p dakota-service -p auth-service -p auth-client # 91
+AUTH_TEST_DATABASE_URL=postgres://…/auth_test \
+ cargo test -p auth-service -- --ignored # 12
+
+# against the live sandbox
+DAKOTA_TEST_API_KEY=… cargo test -p dakota-service -- --ignored live
+
+# whole story, against running services
+AUTH=… AUTHI=… DK=… rust-backend/services/dakota-service/smoke.sh # 31
+```
+
+`smoke.sh` covers admin bootstrap, the three-tier hierarchy, cross-scope
+isolation, the approval gate, all three ramps, the catalog and network
+allow-lists, the $2 cap, sandbox funding, the ledger, and webhook authenticity.
+
+The live signing test is worth understanding: an **insufficient-balance**
+rejection is *success*. It means the signature verified and Dakota reached
+policy evaluation. `endorsement validation failed` is the failure — and it names
+nothing, which is why two undocumented signing rules cost real debugging time
+(see the sandbox notes).
+
+---
+
+## The no-PII policy, and how to not break it
+
+Dakota responses are full of PII — `GET /customers` returns `email` and `name`,
+`POST /accounts` returns `bank_account.account_holder_name` and
+`account_number`, `GET /events` returns `sender_details`. None of it is stored.
+
+Three rules hold the line:
+
+1. **No identifying column exists.** The schema has nowhere to put a name, so a
+ careless write fails to compile rather than leaking.
+2. **No raw response body is persisted.** The webhook receiver extracts ids,
+ enums, amounts and assets and drops the rest — deliberately unlike the
+ indexer's `indexed_events.payload` envelope. A delivery that fails to parse
+ is recorded as a SHA-256 of the body, never the body.
+3. **Handlers that display a name relay `serde_json::Value`** straight to the
+ browser instead of binding a struct.
+
+Onboarding follows from the same policy: customers are handed to Dakota's hosted
+`application_url`, and beneficial owners, documents and SSNs never touch our
+code.
+
+Audit before merging anything that touches the schema:
+
+```sh
+grep -rniE '\b(name|email|ssn|dob|phone|address)\b' \
+ rust-backend/services/dakota-service/src/db/migrations/*/up.sql
+# expected: only wallets.address, a blockchain address
+```
+
+---
+
+## Deferred: Sumsub import
+
+Dakota sandbox does accept Sumsub **sandbox** share tokens — they are
+environment-scoped (`sbx` vs `lv` prefix) and must be redeemed in the
+environment that minted them. `POST /customers/bulk-import-sumsub-tokens` takes
+1–100 tokens and always returns `200` with per-row `success`.
+
+It is not self-serve, and two prerequisites are missing:
+
+1. a **Dakota-issued partner token** for the sandbox environment — only from a
+ Dakota representative, expires 30 days after creation;
+2. the **"Share applicants data"** permission on our Sumsub app token.
+
+Further limits: individual applicants only (business onboarding is explicitly
+out of scope), Dakota redeems at the `id-only` verification level, and imported
+applications land in **draft** missing employment status, SSN and attestations.
+Completing those via API would mean handling SSNs, so the hosted form is the
+only no-PII completion path.
+
+KYC therefore ships hosted-redirect-only until someone chases the partner token.
diff --git a/docs/dakota-sandbox-notes.md b/docs/dakota-sandbox-notes.md
new file mode 100644
index 00000000..1fc594c7
--- /dev/null
+++ b/docs/dakota-sandbox-notes.md
@@ -0,0 +1,218 @@
+# Dakota sandbox — verified behaviour
+
+Findings from probing `https://api.platform.sandbox.dakota.xyz` live with our sandbox API key
+(2026-08-02). These supersede the prose docs wherever they disagree — several documented
+shapes are wrong or incomplete.
+
+Our sandbox client id is `3HN0RQshF6yCiMXxhCD7yIJarU9` ("Pismo Protocol").
+
+## Auth and conventions
+
+- `x-api-key: ` on every request. `x-idempotency-key: ` on every **POST** — omitting
+ it is a `400`. Do **not** send it on GET/PUT/PATCH/DELETE.
+- Errors are RFC 9457 Problem Details: `{type, title, status, detail, instance, request_id}`,
+ plus an `errors[]` array of `{field, message, code}` on validation failures.
+- Ids are KSUIDs (27 chars).
+
+## What does NOT exist
+
+- **No token-issuance API.** Nothing creates a stablecoin. Our "supported assets" catalog is
+ ours to own.
+- **No assets endpoint.** The only capability routes are `/capabilities/networks` and
+ `/capabilities/countries`. `/info/networks` is a `404` — the docs' path is wrong.
+- **`GET /self-serve/credits/pricing` → `403`**: *"Credit management is only available for
+ self-serve customers."* We are not a self-serve client, so there is **no fee-schedule
+ endpoint available to us**. Rates must be admin-entered.
+- `GET /wallets` → `405`. The collection is POST-only; there is no list-wallets route.
+
+## Other traps found by running it
+
+- **`GET /events?limit` caps at 100.** Asking for more is a `400`, not a silent clamp.
+- **Receipts come in two shapes.** `GET /auto-transactions` nests them
+ (`{"output":{"amount":"2","asset":"USDC"}}`); `GET /events` and webhook deliveries flatten
+ them (`{"outgoing_amount":"2","output_currency":"USDC"}`), and there `dakota_fee` is a bare
+ decimal string rather than an object. Handling only the nested form leaves every
+ webhook-sourced ledger row with a NULL amount.
+- **Events name the account, not the customer.** There is no `customer_id` on an event object —
+ only `auto_account_id`. Attribution has to come from your own account→customer mapping, or
+ every per-customer total stays empty.
+- **`simulate/onboarding` does not push a status update you can rely on in the same breath.**
+ The simulation returns `approved`, but a local copy of `kyb_status` only catches up when the
+ webhook lands. Anything that gates on the local status (as `POST /accounts` does) must
+ re-read `GET /customers/{id}` right after simulating, or the next call is still refused as
+ `pending`.
+- **Postgres widens `SUM(bigint)` to `NUMERIC`.** Unrelated to Dakota, but it broke the flow
+ aggregation until the SUMs were cast back with `::bigint`.
+
+## Where rates actually come from
+
+Not from a pricing endpoint — from **transaction receipts**. Every auto-transaction carries:
+
+```json
+"receipt": { "input": {"amount":"2","asset":"USD"}, "output": {"amount":"2","asset":"USDC"},
+ "exchange_rate": "1", "dakota_fee": {"amount":"0","asset":"USD"},
+ "client_fee": {...}, "external_fee": {...} }
+```
+
+So the rates view is: admin-entered expected schedule + realised `exchange_rate`/fee history
+derived from completed transactions.
+
+## `GET /capabilities/networks` (verified)
+
+```
+arbitrum-mainnet, arbitrum-sepolia, base-mainnet, base-sepolia, ethereum-goerli,
+ethereum-holesky, ethereum-mainnet, ethereum-sepolia, evm, optimism-mainnet,
+optimism-sepolia, polygon-amoy, polygon-mainnet, solana-devnet, solana-testnet,
+solana-mainnet
+```
+
+Mainnets are **listed but rejected** by object-create endpoints in sandbox. `evm` is a
+wildcard valid in all environments.
+
+## Onboarding state machine — the gate that matters
+
+`POST /accounts` fails with `"Customer is not KYB-approved by Dakota"` until the customer is
+approved. Getting there in sandbox:
+
+```
+POST /sandbox/simulate/onboarding
+{ "type": "kyb_approve", "applicant_id": "", "simulation_id": "" }
+```
+
+**`kyb_approve` is the master transition — use it for individuals too.** Confirmed traps:
+
+- The body needs `type`, `applicant_id`, `simulation_id`. `applicant_id` is the
+ **`application_id`**, not the customer id. There is no `customer_id`/`target_status` field
+ (the docs' example is wrong).
+- `kyc_approve` on a fresh individual is a **no-op** (`not_started → not_started`). Only
+ `kyb_approve` advances it. After `kyb_approve` the customer shows `kyb_status: "active"`
+ while `kyc_status` stays `"pending"` — and that is sufficient for `/accounts`.
+- `applicant_activate` is idempotent once approved.
+
+Customer status fields: `kyb_status`, `kyc_status`, `application_status`, plus `rd_allowed`.
+
+## Three-tier hierarchy (verified working)
+
+```
+POST /customers {"name","customer_type":"business","is_sub_client":true} → sub-client
+POST /customers {"name","customer_type":"individual","sub_client_id":""} → its customer
+```
+
+`GET /customers/sub-client-summary` → `[{sub_client_id, sub_client_name, customer_count}]`.
+
+`POST /customers` returns `application_url` (hosted form, embedded token) and
+`application_expires_at` — **nanoseconds**, not seconds, unlike every other timestamp.
+
+`GET /customers/{id}/capabilities` returns per-capability `requirements[]` with
+`{key, severity, title, type, url}` — ideal for a "what's needed to unlock" panel.
+
+## Ramp flow (verified end-to-end)
+
+1. `POST /customers/{id}/recipients` — `{name}`. Address optional for crypto-only; **required
+ before adding any fiat destination**.
+2. `POST /recipients/{id}/destinations` — discriminated by `destination_type`:
+ `crypto` / `fiat_us` / `fiat_iban`. Crypto needs `{name, crypto_address, network_id}`.
+3. `POST /accounts`:
+ - **onramp** — `capabilities` is **required** (`["ach","fedwire"]`); undocumented as
+ required, fails `400 "capabilities are required"` without it. Returns a full
+ `bank_account` (Lead Bank, ABA + account number).
+ - **swap** — returns `source_crypto_address` on the source network.
+ - **offramp** — needs `fiat_destination_id`, so the recipient must have an address.
+
+Verified onramp: `$2.00 USD → 2 USDC` on `base-sepolia`, status `processing`.
+
+## Sandbox limits
+
+- **$2.00 cap per transaction.** `5.00` → `400 "amount 5 exceeds sandbox cap of 2"`.
+- USDT unsupported. USD, USDC and RD treated 1:1.
+- `POST /sandbox/simulate/inbound` needs `{simulation_id, type, amount, currency}` plus
+ `account_id` (ACH/Fedwire/FedNow inbound) or `wallet_address` (`crypto_inbound`).
+
+## Wallets — supported in sandbox
+
+Full chain verified. Wallet `0xF2e1556b5b41e71244685C6e64e5Dc6C64e1d62B` created.
+
+```
+POST /signers {name, public_key, key_type:"ES256"} # base64 DER SPKI (X.509 PKIX)
+POST /signer-groups {name, member_keys:[]} # public keys, NOT signer ids
+POST /policies {name, description, signer_group_id, rules:[...]}
+POST /wallets {name, family:"evm"|"solana", signer_groups:[id], policies:[id]}
+GET /wallets/{id}/balances → {address, balances[], total_amount_usd}
+```
+
+Quirks: `key_type` echoes back as `KEY_TYPE_ES256`, not `ES256`. `POST /policies` accepts
+`signer_group_id` but **returns it as `null`** — attach via the wallet instead.
+
+### Endorsed (signed) requests — broader than documented
+
+**Nine** endpoints take an `EndorsedRequest` (`{signatures:[base64], intent:{...}}`), not just
+transactions:
+
+```
+POST /wallets/{id}/transactions PUT /policies/{pid}/wallets/{wid}
+POST /policies/{pid}/rules DELETE /policies/{pid}/wallets/{wid}
+PATCH /policies/{pid}/rules/{rid} DELETE /policies/{pid}
+DELETE /policies/{pid}/rules/{rid}
+PUT /wallets/{wid}/signer-groups/{gid} DELETE /wallets/{wid}/signer-groups/{gid}
+```
+
+Signing: **RFC 8785 JCS canonicalize → SHA-256 → ECDSA P-256 → ASN.1 DER → base64**.
+`snake_case` keys, amounts as strings, unset fields omitted. Browser `crypto.subtle` returns
+IEEE P1363 `r||s` and must be converted to DER.
+
+### Two undocumented rules that both fail as `endorsement validation failed`
+
+That error is the *only* feedback you get, and it names nothing. Both of these cost real
+debugging time and are now covered by tests in `services/dakota-service/src/wallet/`.
+
+**1. Amounts must be normalized before signing.** Dakota normalizes the decimal before
+rebuilding the intent it verifies against, so a signature over `"1.00"` is checked against
+`"1"`. Measured against the live sandbox with one key and one wallet, varying only the amount:
+
+| amount sent | result |
+|---|---|
+| `"1"` | accepted → *Insufficient balance… Required: 1 USDC* |
+| `"1.00"` | **endorsement validation failed** |
+| `"0.50"` | **endorsement validation failed** |
+| `"0.01"` | accepted → *Insufficient balance… Required: 0.01 USDC* |
+
+Strip trailing zeros from the fraction and drop the point if nothing remains:
+`"1.00"` → `"1"`, `"0.50"` → `"0.5"`, `"0.01"` unchanged. That is `wallet::normalize_amount`.
+Every whole-dollar transfer a person types would otherwise be rejected.
+
+**2. Transmit the canonical form, not the struct.** A Rust struct serializes in *declaration*
+order, so posting one sends key order that differs from the canonical bytes that were signed.
+`serde_json::Value` orders its keys, so `endorse()` returns the intent as a `Value` rebuilt
+from the canonical bytes — the wire form then equals the signed form by construction.
+
+A useful diagnostic property: an **insufficient-balance** rejection is *success* for signing
+purposes. It means the signature verified and Dakota reached policy evaluation. That is what
+the `live_signature_is_accepted_by_dakota` test asserts on.
+
+## PII exposure — why we store almost nothing
+
+Dakota responses are full of PII. Confirmed in live responses:
+
+- `GET /customers` → `email`, `name`
+- `POST /accounts` (onramp) → `bank_account.account_holder_name`, `account_number`,
+ `aba_routing_number`
+- `GET /events` → `sender_details.sender_account_holder_name`, `sender_account_number`
+
+**Therefore: never persist a Dakota response body.** Extract only ids, enums, amounts, assets
+and timestamps. Proxy everything else straight to the browser.
+
+## Probe artifacts left in sandbox
+
+| Kind | Id |
+|---|---|
+| signer | `3HNC7kMf188HuSKgFWXqKJreTqv` |
+| signer group | `3HNC8vt3NOat7GWDRJgeVe27Kru` |
+| policy | `3HNC8wVOBg2KRTVWl50owNTH3i2` |
+| wallet (evm) | `3HNC95HOlmEHtkb8iGQt9WScIvG` / `0xF2e1556b…` |
+| sub-client | `3HNCB4vp2zWMwdfoY33qKK11iOJ` "Acme Partner Bank" |
+| individual | `3HNCB1zUMQe4bUmiYHPw6xMPcOr` "Jane Probe" |
+| onramp account | `3HNCN914HGh2Sr95XpcJBgMPLAT` |
+| swap account | `3HNCNG7l9WZzwGSjlLlWBzccg4v` |
+
+The probe's P-256 private key was scratchpad-only and is **not** the treasury key — Phase 4
+generates its own into Secrets Manager.
diff --git a/rust-backend/Cargo.lock b/rust-backend/Cargo.lock
index 35da4b10..6478f0ae 100644
--- a/rust-backend/Cargo.lock
+++ b/rust-backend/Cargo.lock
@@ -2302,6 +2302,42 @@ dependencies = [
"zeroize",
]
+[[package]]
+name = "dakota-service"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "auth-client",
+ "axum 0.7.9",
+ "base64 0.22.1",
+ "bigdecimal",
+ "chrono",
+ "clap",
+ "cli-spec",
+ "config",
+ "diesel",
+ "diesel_migrations",
+ "ed25519-dalek",
+ "hex",
+ "metrics",
+ "observability",
+ "p256",
+ "r2d2",
+ "rand 0.8.6",
+ "reqwest",
+ "runtime-config",
+ "serde",
+ "serde_jcs",
+ "serde_json",
+ "sha2 0.10.9",
+ "thiserror 1.0.69",
+ "tokio",
+ "tower-http 0.6.10",
+ "tracing",
+ "tracing-subscriber",
+ "uuid",
+]
+
[[package]]
name = "darling"
version = "0.14.4"
@@ -9026,6 +9062,12 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
+[[package]]
+name = "ryu-js"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6518fc26bced4d53678a22d6e423e9d8716377def84545fe328236e3af070e7f"
+
[[package]]
name = "same-file"
version = "1.0.6"
@@ -9310,6 +9352,17 @@ dependencies = [
"syn 2.0.117",
]
+[[package]]
+name = "serde_jcs"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cacecf649bc1a7c5f0e299cc813977c6a78116abda2b93b1ee01735b71ead9a8"
+dependencies = [
+ "ryu-js",
+ "serde",
+ "serde_json",
+]
+
[[package]]
name = "serde_json"
version = "1.0.149"
diff --git a/rust-backend/Cargo.toml b/rust-backend/Cargo.toml
index d473a344..38166a59 100644
--- a/rust-backend/Cargo.toml
+++ b/rust-backend/Cargo.toml
@@ -30,6 +30,7 @@ members = [
"services/market-sim",
"services/price-charting",
"services/cctp-relay",
+ "services/dakota-service",
"services/balance-monitor",
"services/oracle-service",
"services/twitter-service",
@@ -105,6 +106,13 @@ base64 = "0.22"
# still verifies hashes written today.
argon2 = "0.5"
+# dakota-service: ECDSA P-256 (ES256) signing of Dakota wallet intents, and
+# RFC 8785 JCS canonicalization of the intent JSON before hashing. Dakota
+# verifies over the transmitted form, so any deviation is a silent signature
+# mismatch — both crates are load-bearing.
+p256 = { version = "0.13", features = ["ecdsa", "pem"] }
+serde_jcs = "0.1"
+
thiserror = "1"
anyhow = "1"
diff --git a/rust-backend/Dockerfile.dakota-service b/rust-backend/Dockerfile.dakota-service
new file mode 100644
index 00000000..8f48ce79
--- /dev/null
+++ b/rust-backend/Dockerfile.dakota-service
@@ -0,0 +1,27 @@
+# Multi-stage build for dakota-service. Mirrors Dockerfile.cctp-relay — see
+# the platform note in Dockerfile.indexer (no $BUILDPLATFORM pinning).
+FROM rust:1-bookworm AS builder
+WORKDIR /src
+
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ pkg-config libssl-dev clang cmake protobuf-compiler git \
+ && rm -rf /var/lib/apt/lists/*
+
+COPY . .
+RUN cargo build --release -p dakota-service
+
+FROM debian:bookworm-slim
+# libpq5: Postgres runtime lib for the diesel connection.
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ ca-certificates libssl3 libpq5 curl && rm -rf /var/lib/apt/lists/*
+WORKDIR /app
+COPY --from=builder /src/target/release/dakota-service /usr/local/bin/dakota-service
+COPY services/dakota-service/config/ /app/config/
+
+# staging only — there is deliberately no config.prod.toml, and the service is
+# not declared in docker-compose.prod.yml.
+ENV APP_ENV=staging
+# --secrets carries `dakota.api_key`, rendered to
+# /run/secrets/dakota-service.toml by render-secrets.sh and bind-mounted by
+# compose. REQUIRED at runtime — every Dakota call needs the key.
+ENTRYPOINT ["/bin/sh", "-c", "exec /usr/local/bin/dakota-service --config /app/config/config.${APP_ENV}.toml --secrets /run/secrets/dakota-service.toml"]
diff --git a/rust-backend/crates/runtime-config/src/secrets.rs b/rust-backend/crates/runtime-config/src/secrets.rs
index 943af25f..f4696bcb 100644
--- a/rust-backend/crates/runtime-config/src/secrets.rs
+++ b/rust-backend/crates/runtime-config/src/secrets.rs
@@ -58,6 +58,19 @@ pub struct Secrets {
pub pyth: PythSecrets,
#[serde(default)]
pub solana: SolanaSecrets,
+ #[serde(default)]
+ pub dakota: DakotaSecrets,
+}
+
+#[derive(Debug, Clone, Deserialize, Default)]
+pub struct DakotaSecrets {
+ /// Dakota platform API key, sent as `x-api-key` on every request. Minted in
+ /// the Dakota dashboard and shown exactly once.
+ pub api_key: Option,
+ /// PEM-encoded ECDSA P-256 private key used to sign wallet intents
+ /// (`EndorsedRequest`). Its public half is registered with Dakota as an
+ /// `ES256` signer; Dakota never sees this side.
+ pub wallet_p256_pem: Option,
}
#[derive(Debug, Clone, Deserialize, Default)]
@@ -207,6 +220,23 @@ impl Secrets {
.ok_or_else(|| anyhow!("secrets.toml is missing auth.jwt_secret"))
}
+ /// Dakota platform API key. Required — dakota-service can do nothing
+ /// without it, so a missing key is a startup failure rather than a
+ /// degraded mode.
+ pub fn dakota_api_key(&self) -> Result<&str> {
+ self.dakota
+ .api_key
+ .as_deref()
+ .ok_or_else(|| anyhow!("secrets.toml is missing dakota.api_key"))
+ }
+
+ /// P-256 signing key for Dakota wallet intents. Optional: the treasury is
+ /// one feature of dakota-service, and the rest of the service works
+ /// without it.
+ pub fn dakota_wallet_p256_pem(&self) -> Option<&str> {
+ self.dakota.wallet_p256_pem.as_deref()
+ }
+
/// Pyth API key if present. Unlike the signing keys this is optional —
/// callers attach it as a Bearer header when set and otherwise fall back
/// to the anonymous (rate-limited) tier.
diff --git a/rust-backend/deployment/affected.py b/rust-backend/deployment/affected.py
index c38d72d1..735ceb2e 100755
--- a/rust-backend/deployment/affected.py
+++ b/rust-backend/deployment/affected.py
@@ -58,7 +58,7 @@
# Order here is the canonical "all services" list. Keep in sync with the
# ALL_SERVICES array in deployment/ec2/deploy.sh — `test_affected.py`
# asserts the two match.
-ALL_SERVICES = ["indexer", "quoting-service", "mm-bot", "option-scheduler", "api-service", "token-info", "auth-service", "gas-station", "hedge-signer", "market-sim", "price-charting", "balance-monitor", "keeper", "oracle-service", "cctp-relay", "twitter-service", "social-bot"]
+ALL_SERVICES = ["indexer", "quoting-service", "mm-bot", "option-scheduler", "api-service", "token-info", "auth-service", "gas-station", "hedge-signer", "market-sim", "price-charting", "balance-monitor", "keeper", "oracle-service", "cctp-relay", "dakota-service", "twitter-service", "social-bot"]
# Path globs that, when matched, force every service to rebuild +
# redeploy. Catches lockfile churn, workspace-wide config, infra-side
@@ -120,6 +120,13 @@
"rust-backend/services/cctp-relay/**",
"rust-backend/Dockerfile.cctp-relay",
],
+ # Staging-only service. It still appears here so a source change rebuilds
+ # its image; what keeps it out of prod is its absence from
+ # docker-compose.prod.yml, which deploy.sh filters against.
+ "dakota-service": [
+ "rust-backend/services/dakota-service/**",
+ "rust-backend/Dockerfile.dakota-service",
+ ],
"gas-station": [
"rust-backend/services/gas-station/**",
"rust-backend/Dockerfile.gas-station",
diff --git a/rust-backend/deployment/bake.hcl b/rust-backend/deployment/bake.hcl
index 59cacd92..3f49a122 100644
--- a/rust-backend/deployment/bake.hcl
+++ b/rust-backend/deployment/bake.hcl
@@ -99,6 +99,16 @@ target "cctp-relay" {
cache-to = [{ type = "gha", mode = "max", scope = "cctp-relay" }]
}
+# Built for every environment, deployed only to staging: the image is harmless
+# to publish, and docker-compose.prod.yml simply never references it.
+target "dakota-service" {
+ inherits = ["_common"]
+ dockerfile = "Dockerfile.dakota-service"
+ tags = ["${ECR}/options/dakota-service:${IMAGE_TAG}"]
+ cache-from = [{ type = "gha", scope = "dakota-service" }]
+ cache-to = [{ type = "gha", mode = "max", scope = "dakota-service" }]
+}
+
target "gas-station" {
inherits = ["_common"]
dockerfile = "Dockerfile.gas-station"
@@ -164,5 +174,5 @@ target "market-sim" {
}
group "default" {
- targets = ["indexer", "quoting-service", "mm-bot", "option-scheduler", "api-service", "token-info", "auth-service", "gas-station", "hedge-signer", "market-sim", "price-charting", "keeper", "balance-monitor", "oracle-service", "cctp-relay", "twitter-service", "social-bot"]
+ targets = ["indexer", "quoting-service", "mm-bot", "option-scheduler", "api-service", "token-info", "auth-service", "gas-station", "hedge-signer", "market-sim", "price-charting", "keeper", "balance-monitor", "oracle-service", "cctp-relay", "dakota-service", "twitter-service", "social-bot"]
}
diff --git a/rust-backend/deployment/compose/docker-compose.prod.yml b/rust-backend/deployment/compose/docker-compose.prod.yml
index 9ffc0683..93bcecd6 100644
--- a/rust-backend/deployment/compose/docker-compose.prod.yml
+++ b/rust-backend/deployment/compose/docker-compose.prod.yml
@@ -172,6 +172,13 @@ services:
# render-secrets.sh skips an absent secret silently and the container would
# crash-loop on the missing /run/secrets/cctp-relay.toml.
+ # NOTE: dakota-service is deliberately NOT declared in prod either, and this
+ # one is by design rather than by circumstance: it integrates Dakota's
+ # SANDBOX (testnet custody, mocked banking, a $2 per-transaction cap), so
+ # there is nothing here it could usefully do. It ships with no
+ # config.prod.toml at all — the image would exit on a missing config file
+ # even if something did try to start it.
+
# Gas station. Sponsors user transactions by paying their gas. Public port
# (9009, proxied by nginx). Reads the sponsor key from /run/secrets.
gas-station:
diff --git a/rust-backend/deployment/compose/docker-compose.staging.yml b/rust-backend/deployment/compose/docker-compose.staging.yml
index 83daf530..11537f8f 100644
--- a/rust-backend/deployment/compose/docker-compose.staging.yml
+++ b/rust-backend/deployment/compose/docker-compose.staging.yml
@@ -171,6 +171,29 @@ services:
restart: unless-stopped
networks: [net]
+ # Dakota stablecoin on/off-ramp integration. Backs the admin, partner-business
+ # and individual dashboards: hosted-redirect onboarding, onramp/offramp/swap
+ # accounts and a PII-free activity ledger. Public port (9019, proxied by nginx
+ # as /staging/dakota — the webhook receiver needs to be internet-reachable).
+ #
+ # STAGING ONLY, and deliberately absent from docker-compose.prod.yml: it talks
+ # to Dakota's SANDBOX, and deploy.sh filters the requested set against the
+ # env's compose file, so omitting it there is what keeps it from ever being
+ # planned into prod. Reads `dakota.api_key` from
+ # /run/secrets/dakota-service.toml — required, the service exits without it.
+ dakota-service:
+ image: ${ECR}/options/dakota-service:${DAKOTA_SERVICE_TAG}
+ environment:
+ APP_ENV: staging
+ OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_ENDPOINT:-}
+ DB_PASSWORD: ${DB_PASSWORD}
+ DB_HOST: ${DB_HOST}
+ RUST_LOG: info,dakota_service=debug
+ volumes:
+ - /opt/options/staging/secrets:/run/secrets:ro
+ restart: unless-stopped
+ networks: [net]
+
# Gas station. Sponsors user transactions by paying their gas. Public port
# (9009, proxied by nginx). Reads the sponsor key from /run/secrets.
gas-station:
diff --git a/rust-backend/deployment/ec2/deploy.sh b/rust-backend/deployment/ec2/deploy.sh
index fda07dde..52bbb12d 100755
--- a/rust-backend/deployment/ec2/deploy.sh
+++ b/rust-backend/deployment/ec2/deploy.sh
@@ -50,7 +50,7 @@ COMPOSE_FILE="docker-compose.${ENV}.yml"
# Canonical service set + their .env tag-variable names + the compose
# service name (mostly identical to the cargo crate name, except
# quoting-service is referenced as `quoting` in compose).
-ALL_SERVICES=(indexer quoting-service mm-bot option-scheduler api-service token-info auth-service gas-station hedge-signer market-sim price-charting balance-monitor keeper oracle-service cctp-relay twitter-service social-bot)
+ALL_SERVICES=(indexer quoting-service mm-bot option-scheduler api-service token-info auth-service gas-station hedge-signer market-sim price-charting balance-monitor keeper oracle-service cctp-relay dakota-service twitter-service social-bot)
tag_var_for() {
case "$1" in
@@ -69,6 +69,7 @@ tag_var_for() {
keeper) echo KEEPER_TAG ;;
oracle-service) echo ORACLE_SERVICE_TAG ;;
cctp-relay) echo CCTP_RELAY_TAG ;;
+ dakota-service) echo DAKOTA_SERVICE_TAG ;;
twitter-service) echo TWITTER_SERVICE_TAG ;;
social-bot) echo SOCIAL_BOT_TAG ;;
*) return 1 ;;
@@ -91,6 +92,7 @@ compose_name_for() {
keeper) echo keeper ;;
oracle-service) echo oracle-service ;;
cctp-relay) echo cctp-relay ;;
+ dakota-service) echo dakota-service ;;
twitter-service) echo twitter-service ;;
social-bot) echo social-bot ;;
*) return 1 ;;
@@ -266,6 +268,7 @@ health_path_for() {
auth-service) echo "/$ENV/auth/health" ;;
price-charting) echo "/$ENV/charts/health" ;;
cctp-relay) echo "/$ENV/cctp/health" ;;
+ dakota-service) echo "/$ENV/dakota/health" ;;
hedge-signer) echo "/$ENV/hedge-signer/health" ;;
market-sim) echo "/$ENV/market-sim/health" ;;
keeper) echo "/$ENV/keeper/health" ;;
diff --git a/rust-backend/deployment/nginx/nginx.staging.conf b/rust-backend/deployment/nginx/nginx.staging.conf
index 2a2a2b08..aa6d22f6 100644
--- a/rust-backend/deployment/nginx/nginx.staging.conf
+++ b/rust-backend/deployment/nginx/nginx.staging.conf
@@ -144,6 +144,19 @@ http {
proxy_set_header X-Forwarded-Proto $scheme;
}
+ # dakota-service: ramp control plane for the Dakota dashboards, and the
+ # webhook receiver Dakota delivers to
+ # (https://sui-options.com/staging/dakota/webhooks/dakota). Staging only —
+ # there is no matching block in nginx.prod.conf, because the service is not
+ # declared in the prod compose file and the upstream would never resolve.
+ location ~ ^/staging/dakota(?:/(?.*))?$ {
+ set $upstream "dakota-service:9019";
+ proxy_pass http://$upstream/$tail$is_args$args;
+ proxy_set_header Host $host;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+
location ~ ^/staging/gas-station(?:/(?.*))?$ {
set $upstream "gas-station:9009";
proxy_pass http://$upstream/$tail$is_args$args;
diff --git a/rust-backend/infra/ecr.tf b/rust-backend/infra/ecr.tf
index 1c6f87a9..7c1d9bca 100644
--- a/rust-backend/infra/ecr.tf
+++ b/rust-backend/infra/ecr.tf
@@ -8,7 +8,10 @@ locals {
# retired. Removing it here destroys the repo on apply — if it still holds
# images, run `terraform state rm 'aws_ecr_repository.svc["derived-metric-worker"]'`
# and delete the repo by hand (or set force_delete) to avoid a destroy error.
- service_repos = ["indexer", "quoting-service", "mm-bot", "option-scheduler", "api-service", "token-info", "auth-service", "gas-station", "hedge-signer", "market-sim", "price-charting", "balance-monitor", "keeper", "oracle-service", "cctp-relay", "twitter-service", "social-bot"]
+ # dakota-service deploys only to staging, but it still needs a repo here:
+ # the image is built and pushed by the shared workflow regardless of which
+ # env consumes it, and a missing repo fails the push with a 403.
+ service_repos = ["indexer", "quoting-service", "mm-bot", "option-scheduler", "api-service", "token-info", "auth-service", "gas-station", "hedge-signer", "market-sim", "price-charting", "balance-monitor", "keeper", "oracle-service", "cctp-relay", "dakota-service", "twitter-service", "social-bot"]
}
resource "aws_ecr_repository" "svc" {
diff --git a/rust-backend/services/dakota-service/Cargo.toml b/rust-backend/services/dakota-service/Cargo.toml
new file mode 100644
index 00000000..f091b26f
--- /dev/null
+++ b/rust-backend/services/dakota-service/Cargo.toml
@@ -0,0 +1,59 @@
+[package]
+name = "dakota-service"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+
+[lib]
+path = "src/lib.rs"
+
+[[bin]]
+name = "dakota-service"
+path = "src/main.rs"
+
+[dependencies]
+runtime-config = { workspace = true }
+observability = { workspace = true, features = ["axum"] }
+cli-spec = { workspace = true }
+auth-client = { workspace = true }
+
+config = { version = "0.14", features = ["toml"] }
+clap = { workspace = true }
+chrono = { workspace = true }
+uuid = { workspace = true }
+
+tokio = { workspace = true }
+
+axum = { workspace = true }
+tower-http = { workspace = true }
+reqwest = { workspace = true }
+
+serde = { workspace = true }
+serde_json = { workspace = true }
+
+thiserror = { workspace = true }
+anyhow = { workspace = true }
+
+tracing = { workspace = true }
+tracing-subscriber = { workspace = true }
+metrics = { workspace = true }
+
+diesel = { workspace = true }
+diesel_migrations = { workspace = true }
+r2d2 = { workspace = true }
+bigdecimal = { workspace = true }
+
+# Webhook authenticity: Dakota signs deliveries with Ed25519, not HMAC.
+ed25519-dalek = { workspace = true }
+base64 = { workspace = true }
+hex = { workspace = true }
+sha2 = { workspace = true }
+
+# Wallet intents: RFC 8785 JCS canonicalization -> SHA-256 -> ECDSA P-256 DER.
+p256 = { workspace = true }
+serde_jcs = { workspace = true }
+
+[dev-dependencies]
+# Live sandbox tests: throwaway P-256 keys and a tokio runtime.
+rand = { workspace = true }
+tokio = { workspace = true }
diff --git a/rust-backend/services/dakota-service/config/config.staging.toml b/rust-backend/services/dakota-service/config/config.staging.toml
new file mode 100644
index 00000000..1f313555
--- /dev/null
+++ b/rust-backend/services/dakota-service/config/config.staging.toml
@@ -0,0 +1,49 @@
+# dakota-service — staging.
+#
+# STAGING ONLY. This service is declared in docker-compose.staging.yml and
+# deliberately absent from docker-compose.prod.yml; deploy.sh filters the
+# requested set against the env's compose file, so leaving it out is what keeps
+# it from ever being planned into prod. Do not add a config.prod.toml.
+#
+# Points at the Dakota SANDBOX, which runs real crypto custody on testnets and
+# mocks the banking rails.
+
+environment = "staging"
+bind_addr = "0.0.0.0:9019"
+
+database_url = "postgresql://dakota_staging:${DB_PASSWORD}@${DB_HOST}:5432/dakota_staging"
+db_pool_size = 4
+
+allowed_origins = ["*"]
+
+[dakota]
+base_url = "https://api.platform.sandbox.dakota.xyz"
+
+# Ed25519 key Dakota signs webhook deliveries with. SANDBOX value — the
+# production key differs, and the wrong one rejects every delivery.
+webhook_public_key = "7a2f771f3a7ac9ae2a95066df35dc0261d7ce354214736cc232d70b3c66f8a5f"
+
+# Where Dakota should deliver. Registered on demand via
+# POST /admin/webhooks/register, not at boot.
+webhook_url = "https://sui-options.com/staging/dakota/webhooks/dakota"
+
+# Sandbox refuses anything over $2.00; matching it here turns a confusing
+# downstream 400 into a local message. Raising this does NOT lift Dakota's cap.
+max_amount_minor = 200
+
+# Testnets only. The sandbox lists mainnet ids in /capabilities/networks and
+# then rejects them on every object-create call, so the intersection is the
+# honest offering — and this stops a mainnet id ever leaving the box.
+allowed_networks = [
+ "ethereum-sepolia",
+ "base-sepolia",
+ "arbitrum-sepolia",
+ "optimism-sepolia",
+ "polygon-amoy",
+ "solana-devnet",
+]
+
+[auth]
+# auth-service's INTERNAL port — never proxied by nginx.
+internal_url = "http://auth-service:9008"
+invite_ttl_secs = 604800
diff --git a/rust-backend/services/dakota-service/config/config.toml b/rust-backend/services/dakota-service/config/config.toml
new file mode 100644
index 00000000..fa5d37b2
--- /dev/null
+++ b/rust-backend/services/dakota-service/config/config.toml
@@ -0,0 +1,42 @@
+# dakota-service — local dev.
+#
+# Points at the Dakota SANDBOX. Requires a local Postgres and a secrets file:
+# createdb dakota_dev
+# cp services/dakota-service/config/secrets.example.toml \
+# services/dakota-service/config/secrets.toml # then fill in the api key
+#
+# auth-service must also be running (`cargo run -p auth-service`) — this
+# service verifies tokens and mints invites over its internal port.
+
+environment = "dev"
+bind_addr = "127.0.0.1:9019"
+
+database_url = "postgresql://postgres:postgres@127.0.0.1:5432/dakota_dev"
+db_pool_size = 4
+
+allowed_origins = ["http://localhost:5174", "http://127.0.0.1:5174"]
+
+[dakota]
+base_url = "https://api.platform.sandbox.dakota.xyz"
+
+# SANDBOX webhook signing key. The production key differs.
+webhook_public_key = "7a2f771f3a7ac9ae2a95066df35dc0261d7ce354214736cc232d70b3c66f8a5f"
+
+# Dakota cannot reach localhost. To exercise webhooks locally, put ngrok in
+# front and set this to the forwarded URL, then POST /admin/webhooks/register.
+# webhook_url = "https://.ngrok.app/webhooks/dakota"
+
+max_amount_minor = 200
+
+allowed_networks = [
+ "ethereum-sepolia",
+ "base-sepolia",
+ "arbitrum-sepolia",
+ "optimism-sepolia",
+ "polygon-amoy",
+ "solana-devnet",
+]
+
+[auth]
+internal_url = "http://127.0.0.1:9008"
+invite_ttl_secs = 604800
diff --git a/rust-backend/services/dakota-service/config/secrets.example.toml b/rust-backend/services/dakota-service/config/secrets.example.toml
new file mode 100644
index 00000000..7f19bb48
--- /dev/null
+++ b/rust-backend/services/dakota-service/config/secrets.example.toml
@@ -0,0 +1,21 @@
+# dakota-service secrets. Copy to `secrets.toml` (gitignored) and fill in.
+#
+# In staging these come from AWS Secrets Manager at `options/staging/dakota-service`,
+# rendered to /run/secrets/dakota-service.toml by render-secrets.sh.
+
+[dakota]
+# Dakota platform API key, minted at platform.sandbox.dakota.xyz. Shown once.
+# Sent as `x-api-key` on every request. REQUIRED — the service will not start
+# without it.
+api_key = "REPLACE_ME"
+
+# PEM-encoded ECDSA P-256 private key for signing wallet intents. Optional:
+# only the treasury features need it. Generate with:
+# openssl ecparam -name prime256v1 -genkey -noout -out p256.key.pem
+# Register the public half with Dakota as an ES256 signer:
+# openssl pkey -in p256.key.pem -pubout -outform DER | base64
+# wallet_p256_pem = """
+# -----BEGIN PRIVATE KEY-----
+# ...
+# -----END PRIVATE KEY-----
+# """
diff --git a/rust-backend/services/dakota-service/smoke.sh b/rust-backend/services/dakota-service/smoke.sh
new file mode 100755
index 00000000..3ace59ad
--- /dev/null
+++ b/rust-backend/services/dakota-service/smoke.sh
@@ -0,0 +1,178 @@
+#!/usr/bin/env bash
+#
+# End-to-end smoke test for the Dakota integration.
+#
+# Exercises the whole story against a running auth-service + dakota-service:
+# admin bootstrap, the three-tier customer hierarchy, scope isolation, the
+# ramps, sandbox funding and the activity ledger. Every assertion is one a
+# regression would actually break.
+#
+# AUTH=http://127.0.0.1:9007 AUTHI=http://127.0.0.1:9008 \
+# DK=http://127.0.0.1:9019 ./smoke.sh
+#
+# Talks to Dakota's SANDBOX through the service, so it creates real sandbox
+# objects (customers, accounts). They are cheap and cannot move real money.
+#
+# Requires: curl, python3.
+
+set -euo pipefail
+
+AUTH="${AUTH:-http://127.0.0.1:9007}"
+AUTHI="${AUTHI:-http://127.0.0.1:9008}"
+DK="${DK:-http://127.0.0.1:9019}"
+RUN="smoke-$(date +%s)"
+
+pass=0; fail=0
+ok() { printf ' \033[32m✓\033[0m %s\n' "$1"; pass=$((pass+1)); }
+bad() { printf ' \033[31m✗\033[0m %s\n' "$1"; fail=$((fail+1)); }
+check(){ if [ "$2" = "$3" ]; then ok "$1"; else bad "$1 (want $3, got $2)"; fi; }
+# Print a field, or the raw body on a parse failure. Dakota rate-limits around
+# 100 req/min and this script is chatty, so a bare traceback here would look
+# like a code bug when it is really a 429.
+jq_(){ python3 -c "
+import sys,json
+raw=sys.stdin.read()
+try:
+ d=json.loads(raw)
+except Exception:
+ sys.stderr.write(' !! non-JSON response: '+raw[:200]+'\n'); sys.exit(1)
+print($1)"; }
+code(){ curl -sS -o /dev/null -w '%{http_code}' "$@"; }
+
+# Dakota rate-limits; a short pause between phases keeps a long run under it.
+breathe(){ sleep "${SMOKE_PAUSE:-2}"; }
+
+section(){ printf '\n\033[1m%s\033[0m\n' "$1"; }
+
+section "1. identity"
+ADMIN_INV=$(curl -sS -X POST "$AUTHI/invites" -H 'content-type: application/json' \
+ -d '{"role":"admin","label":"smoke"}' | jq_ "d['invite_id']")
+AT=$(curl -sS -X POST "$AUTH/register" -H 'content-type: application/json' \
+ -d "{\"invite\":\"$ADMIN_INV\",\"username\":\"$RUN-admin\",\"password\":\"correct horse battery staple\"}" | jq_ "d['token']")
+[ -n "$AT" ] && ok "admin registered from an invite" || bad "admin registration"
+
+check "an invite is single-use" \
+ "$(code -X POST "$AUTH/register" -H 'content-type: application/json' \
+ -d "{\"invite\":\"$ADMIN_INV\",\"username\":\"$RUN-dupe\",\"password\":\"correct horse battery staple\"}")" 400
+
+ROLE=$(curl -sS -X POST "$AUTH/login/password" -H 'content-type: application/json' \
+ -d "{\"username\":\"$RUN-admin\",\"password\":\"correct horse battery staple\"}" | jq_ "d['role']")
+check "password login returns the admin role" "$ROLE" admin
+check "a wrong password is refused" \
+ "$(code -X POST "$AUTH/login/password" -H 'content-type: application/json' \
+ -d "{\"username\":\"$RUN-admin\",\"password\":\"wrong\"}")" 401
+check "an unknown user is refused identically" \
+ "$(code -X POST "$AUTH/login/password" -H 'content-type: application/json' \
+ -d '{"username":"nobody-at-all","password":"whatever"}')" 401
+
+AH="authorization: Bearer $AT"
+
+section "2. catalog"
+curl -sS -X PUT "$DK/admin/assets" -H "$AH" -H 'content-type: application/json' \
+ -d '{"symbol":"USDC","network_id":"base-sepolia","onramp_enabled":true,"offramp_enabled":true,"swap_enabled":true,"sort_order":0}' >/dev/null
+ok "asset enabled"
+NETS=$(curl -sS "$DK/catalog" -H "$AH" | jq_ "len([n for n in d['networks'] if 'mainnet' in n])")
+check "mainnets are filtered out of the offering" "$NETS" 0
+
+breathe
+section "3. hierarchy"
+BIZ=$(curl -sS -X POST "$DK/customers" -H "$AH" -H 'content-type: application/json' \
+ -d "{\"name\":\"$RUN Partner\",\"customer_type\":\"business\",\"external_ref\":\"$RUN-biz\",\"is_sub_client\":true,\"with_invite\":true}")
+BIZ_ID=$(echo "$BIZ" | jq_ "d['customer']['dakota_customer_id']")
+BIZ_INV=$(echo "$BIZ" | jq_ "d['invite']['invite_id']")
+echo "$BIZ" | jq_ "d['application_url']" | grep -q 'platform.sandbox.dakota.xyz/applications' \
+ && ok "hosted onboarding url returned (no PII collected by us)" || bad "application_url"
+
+BT=$(curl -sS -X POST "$AUTH/register" -H 'content-type: application/json' \
+ -d "{\"invite\":\"$BIZ_INV\",\"username\":\"$RUN-biz\",\"password\":\"another good long passphrase\"}" | jq_ "d['token']")
+BH="authorization: Bearer $BT"
+check "the business session is scoped to itself" "$(curl -sS "$AUTH/me" -H "$BH" | jq_ "d['scope']")" "$BIZ_ID"
+
+IND=$(curl -sS -X POST "$DK/customers" -H "$BH" -H 'content-type: application/json' \
+ -d "{\"name\":\"$RUN Jane\",\"customer_type\":\"individual\",\"external_ref\":\"$RUN-jane\",\"with_invite\":true}")
+IND_ID=$(echo "$IND" | jq_ "d['customer']['dakota_customer_id']")
+IND_INV=$(echo "$IND" | jq_ "d['invite']['invite_id']")
+check "the business's customer is filed beneath it" \
+ "$(echo "$IND" | jq_ "d['customer']['sub_client_id']")" "$BIZ_ID"
+
+IT=$(curl -sS -X POST "$AUTH/register" -H 'content-type: application/json' \
+ -d "{\"invite\":\"$IND_INV\",\"username\":\"$RUN-jane\",\"password\":\"jane has a long passphrase\"}" | jq_ "d['token']")
+IH="authorization: Bearer $IT"
+
+OTHER_ID=$(curl -sS -X POST "$DK/customers" -H "$AH" -H 'content-type: application/json' \
+ -d "{\"name\":\"$RUN Outsider\",\"customer_type\":\"individual\",\"external_ref\":\"$RUN-out\"}" | jq_ "d['customer']['dakota_customer_id']")
+
+section "4. isolation"
+check "business reads its own customer" "$(code "$DK/customers/$IND_ID" -H "$BH")" 200
+check "business cannot read an outsider" "$(code "$DK/customers/$OTHER_ID" -H "$BH")" 404
+check "individual reads itself" "$(code "$DK/customers/$IND_ID" -H "$IH")" 200
+check "individual cannot read an outsider" "$(code "$DK/customers/$OTHER_ID" -H "$IH")" 404
+check "business cannot reach an admin route" "$(code -X POST "$DK/admin/resync" -H "$BH")" 403
+check "individual cannot reach an admin route" "$(code "$DK/admin/treasury" -H "$IH")" 403
+check "no token is refused" "$(code "$DK/customers")" 401
+check "a garbage token is refused" "$(code "$DK/customers" -H 'authorization: Bearer not.a.token')" 401
+curl -sS -X POST "$DK/customers" -H "$BH" -H 'content-type: application/json' \
+ -d "{\"name\":\"Forged\",\"customer_type\":\"individual\",\"sub_client_id\":\"$OTHER_ID\"}" \
+ | grep -q 'beneath itself' && ok "a forged sub_client_id is refused" || bad "forged sub_client_id"
+
+breathe
+section "5. approval gate"
+curl -sS -X POST "$DK/accounts" -H "$AH" -H 'content-type: application/json' \
+ -d "{\"customer_id\":\"$IND_ID\",\"account_type\":\"onramp\",\"destination_asset\":\"USDC\",\"destination_network_id\":\"base-sepolia\",\"source_asset\":\"USD\"}" \
+ | grep -q 'not approved to transact' && ok "an unapproved customer cannot open a ramp" || bad "approval gate"
+
+NEW=$(curl -sS -X POST "$DK/admin/sandbox/onboarding" -H "$AH" -H 'content-type: application/json' \
+ -d "{\"customer_id\":\"$IND_ID\"}" | jq_ "d['new_state']")
+check "kyb_approve advances the application" "$NEW" approved
+
+breathe
+section "6. ramps"
+REC=$(curl -sS -X POST "$DK/customers/$IND_ID/recipients" -H "$AH" -H 'content-type: application/json' \
+ -d '{"name":"Smoke recipient"}' | jq_ "d['id']")
+DEST=$(curl -sS -X POST "$DK/recipients/$REC/destinations" -H "$AH" -H 'content-type: application/json' \
+ -d "{\"customer_id\":\"$IND_ID\",\"destination_type\":\"crypto\",\"name\":\"smoke\",\"crypto_address\":\"0xF2e1556b5b41e71244685C6e64e5Dc6C64e1d62B\",\"network_id\":\"base-sepolia\"}" | jq_ "d['id']")
+
+ON=$(curl -sS -X POST "$DK/accounts" -H "$AH" -H 'content-type: application/json' \
+ -d "{\"customer_id\":\"$IND_ID\",\"account_type\":\"onramp\",\"crypto_destination_id\":\"$DEST\",\"destination_network_id\":\"base-sepolia\",\"source_asset\":\"USD\",\"destination_asset\":\"USDC\"}")
+ON_ID=$(echo "$ON" | jq_ "d['id']")
+echo "$ON" | jq_ "d['bank_account']['aba_routing_number']" | grep -qE '^[0-9]{9}$' \
+ && ok "onramp returns real ACH details" || bad "onramp bank details"
+
+curl -sS -X POST "$DK/accounts" -H "$AH" -H 'content-type: application/json' \
+ -d "{\"customer_id\":\"$IND_ID\",\"account_type\":\"onramp\",\"destination_asset\":\"DOGE\",\"destination_network_id\":\"base-sepolia\",\"source_asset\":\"USD\"}" \
+ | grep -q 'not enabled' && ok "an un-catalogued asset is refused" || bad "catalog allow-list"
+curl -sS -X POST "$DK/accounts" -H "$AH" -H 'content-type: application/json' \
+ -d "{\"customer_id\":\"$IND_ID\",\"account_type\":\"onramp\",\"destination_asset\":\"USDC\",\"destination_network_id\":\"ethereum-mainnet\",\"source_asset\":\"USD\"}" \
+ | grep -q 'not permitted' && ok "a mainnet network never leaves the box" || bad "network allow-list"
+
+breathe
+section "7. funding and the ledger"
+curl -sS -X POST "$DK/admin/sandbox/inbound" -H "$AH" -H 'content-type: application/json' \
+ -d "{\"type\":\"ach_inbound\",\"amount\":\"5.00\",\"account_id\":\"$ON_ID\"}" \
+ | grep -q 'exceeds the configured cap' && ok "the \$2 sandbox cap is enforced locally" || bad "amount cap"
+
+curl -sS -X POST "$DK/admin/sandbox/inbound" -H "$AH" -H 'content-type: application/json' \
+ -d "{\"type\":\"ach_inbound\",\"amount\":\"2.00\",\"account_id\":\"$ON_ID\"}" >/dev/null
+ok "deposit simulated"
+sleep 6
+
+RS=$(curl -sS -X POST "$DK/admin/resync" -H "$AH")
+echo "$RS" | jq_ "d['scanned']" | grep -qE '^[0-9]+$' && ok "resync ran: $(echo "$RS" | jq_ "d")" || bad "resync"
+
+TOT=$(curl -sS "$DK/flows" -H "$AH" | jq_ "sum(t['inbound_minor'] for t in d['totals'])")
+[ "${TOT:-0}" -gt 0 ] && ok "inbound value recorded: $TOT minor units" || bad "flows totals are empty"
+
+section "8. webhook authenticity"
+check "an unsigned delivery is refused" \
+ "$(code -X POST "$DK/webhooks/dakota" -H 'content-type: application/json' -d '{"type":"x"}')" 401
+check "a forged signature is refused" \
+ "$(code -X POST "$DK/webhooks/dakota" -H 'content-type: application/json' \
+ -H 'x-webhook-signature: AAAA' -H "x-webhook-timestamp: $(date +%s)" \
+ -H 'x-dakota-event-id: forged' -d '{"amount":"9999"}')" 401
+check "a stale timestamp is refused" \
+ "$(code -X POST "$DK/webhooks/dakota" -H 'content-type: application/json' \
+ -H 'x-webhook-signature: AAAA' -H "x-webhook-timestamp: $(( $(date +%s) - 999 ))" \
+ -H 'x-dakota-event-id: stale' -d '{}')" 401
+
+printf '\n\033[1m%d passed, %d failed\033[0m\n' "$pass" "$fail"
+[ "$fail" -eq 0 ]
diff --git a/rust-backend/services/dakota-service/src/authz.rs b/rust-backend/services/dakota-service/src/authz.rs
new file mode 100644
index 00000000..703935c9
--- /dev/null
+++ b/rust-backend/services/dakota-service/src/authz.rs
@@ -0,0 +1,234 @@
+//! Role and scope enforcement.
+//!
+//! The rule this module exists to enforce: **scope comes only from the verified
+//! JWT**, never from a path parameter, query string or request body. A business
+//! session asking about customer X is answered only if X is genuinely beneath
+//! that business; an individual session is confined to itself.
+//!
+//! `auth_client::require_auth` has already run and inserted [`VerifiedClaims`]
+//! into the request extensions, so everything here is a pure function of those
+//! claims plus the read model.
+
+use std::sync::Arc;
+
+use auth_client::VerifiedClaims;
+use axum::http::StatusCode;
+use tracing::warn;
+
+use crate::state::AppState;
+
+pub type AuthzError = (StatusCode, String);
+
+/// Who is calling, reduced to the two things that decide access.
+#[derive(Debug, Clone)]
+pub enum Caller {
+ /// Unscoped. Sees and does everything.
+ Admin,
+ /// A partner business, scoped to its own sub-client id.
+ Business { sub_client_id: String },
+ /// A single end customer.
+ Individual { customer_id: String },
+}
+
+impl Caller {
+ pub fn from_claims(claims: &VerifiedClaims) -> Result {
+ match claims.role.as_str() {
+ "admin" => Ok(Caller::Admin),
+ "business" => claims
+ .scope
+ .clone()
+ .map(|sub_client_id| Caller::Business { sub_client_id })
+ .ok_or_else(|| unscoped("business")),
+ "individual" => claims
+ .scope
+ .clone()
+ .map(|customer_id| Caller::Individual { customer_id })
+ .ok_or_else(|| unscoped("individual")),
+ other => {
+ warn!(role = other, "unknown role on a verified token");
+ Err((StatusCode::FORBIDDEN, "unknown role".into()))
+ }
+ }
+ }
+
+ pub fn is_admin(&self) -> bool {
+ matches!(self, Caller::Admin)
+ }
+
+ /// The sub-client filter to apply when listing. `None` means unfiltered,
+ /// which only an admin ever gets.
+ pub fn sub_client_filter(&self) -> Option<&str> {
+ match self {
+ Caller::Admin => None,
+ Caller::Business { sub_client_id } => Some(sub_client_id),
+ // An individual has no roster; list handlers must use
+ // `visible_customer` instead of this.
+ Caller::Individual { .. } => None,
+ }
+ }
+
+ /// Admin-only gate for control-plane routes.
+ pub fn require_admin(&self) -> Result<(), AuthzError> {
+ if self.is_admin() {
+ Ok(())
+ } else {
+ Err((StatusCode::FORBIDDEN, "admin only".into()))
+ }
+ }
+}
+
+/// A scope-unset token for a role that requires one is a bug upstream, not a
+/// permission question — fail closed and loudly rather than defaulting to
+/// "sees everything".
+fn unscoped(role: &str) -> AuthzError {
+ warn!(role, "token carries a scoped role but no scope");
+ (
+ StatusCode::FORBIDDEN,
+ format!("{role} token is missing its scope"),
+ )
+}
+
+/// Authorize access to one customer, returning it.
+///
+/// Admin: anything. Individual: only itself. Business: only customers whose
+/// `sub_client_id` is the business — checked against the read model, because
+/// the caller could otherwise name any id it liked.
+pub fn authorize_customer(
+ state: &Arc,
+ caller: &Caller,
+ customer_id: &str,
+) -> Result {
+ let customer = state
+ .repo
+ .get_customer(customer_id)
+ .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
+ // 404, not 403: telling a caller that an id exists but is off-limits
+ // lets them enumerate the customer base.
+ .ok_or((StatusCode::NOT_FOUND, "unknown customer".to_string()))?;
+
+ let permitted = match caller {
+ Caller::Admin => true,
+ Caller::Individual { customer_id: own } => own == customer_id,
+ Caller::Business { sub_client_id } => {
+ customer.sub_client_id.as_deref() == Some(sub_client_id.as_str())
+ // A business can also see its own record.
+ || customer.dakota_customer_id == *sub_client_id
+ }
+ };
+
+ if !permitted {
+ warn!(caller = ?caller, customer_id, "cross-scope access refused");
+ return Err((StatusCode::NOT_FOUND, "unknown customer".into()));
+ }
+ Ok(customer)
+}
+
+/// The `sub_client_id` a newly created customer must be filed under.
+///
+/// A business may only create customers beneath itself — the value is taken
+/// from its token, so a forged body cannot place a customer under someone
+/// else. An admin may place a customer anywhere, including nowhere.
+pub fn creation_sub_client(
+ caller: &Caller,
+ requested: Option<&str>,
+) -> Result