Skip to content

[SO-336] Add the Dakota stablecoin on/off-ramp integration (sandbox, staging-only) - #382

Open
ewitulsk wants to merge 1 commit into
ewitulsk/auth/multi-method-identityfrom
ewitulsk/dakota/sandbox-mvp
Open

[SO-336] Add the Dakota stablecoin on/off-ramp integration (sandbox, staging-only)#382
ewitulsk wants to merge 1 commit into
ewitulsk/auth/multi-method-identityfrom
ewitulsk/dakota/sandbox-mvp

Conversation

@ewitulsk

@ewitulsk ewitulsk commented Aug 2, 2026

Copy link
Copy Markdown
Owner

SO-336

Stacked on #387 (SO-341), which reworks auth-service into the multi-method identity service this depends on. Review and merge that first — this PR's base retargets to staging automatically once it lands.

Adds dakota-service fronting Dakota's sandbox and the dakota-dashboard console. Runbook in docs/dakota-rollout.md; verified API behaviour in docs/dakota-sandbox-notes.md.

Dakota's object model carries the three tiers we wanted directly:

us (Client) ─┬─ partner business  (Customer, is_sub_client: true)
             │     └─ its customers (Customer, sub_client_id: <business>)
             └─ our own customers  (Customer, no sub_client_id)

One dashboard build serves all of it. The JWT's role + scope decide what renders, and dakota-service enforces the same boundary server-side — the UI never filters data it wasn't already scoped out of.

Never storing PII, and why that's structural

Dakota responses are full of it — GET /customers returns email and name, POST /accounts returns bank_account.account_holder_name and account_number, GET /events returns sender_details. None is persisted. 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 stored. 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: customers go to Dakota's hosted application_url, and beneficial owners, documents and SSNs never touch our code.

Six bugs that only running it could find

Each of these passes unit tests and fails against the real API.

  • Amounts must be normalized before signing. Dakota normalizes the decimal before verifying, so a signature over "1.00" is checked against "1" and fails as endorsement validation failed — a message naming nothing. Every whole-dollar transfer a user typed would have been rejected. Isolated by holding key and wallet fixed and varying only the amount:

    sent result
    "1" accepted → Insufficient balance… Required: 1 USDC
    "1.00" endorsement validation failed
    "0.50" endorsement validation failed
    "0.01" accepted
  • The transmitted intent must be the canonical form. A Rust struct serializes in declaration order, which differs from the signed bytes. endorse() now returns the intent as a Value rebuilt from the canonical bytes, so wire form equals signed form by construction.

  • simulate/onboarding left the local status stale — Approve reported success, then the very next ramp was refused as pending. Caught by the smoke script; an earlier manual run only passed because it happened to re-read the customer in between.

  • /events uses a flat receipt shape (outgoing_amount/output_currency, bare-string fee) unlike /auto-transactions. Handling only the nested form left every webhook-sourced row with a NULL amount.

  • Events name auto_account_id, never customer_id — without joining through our accounts table, every per-customer total stayed empty.

  • Postgres widens SUM(bigint) to NUMERIC, which broke the flow aggregation until cast back with ::bigint.

Also undocumented and now handled: onramps require capabilities; kyb_approve is the transition that advances individuals too (kyc_approve no-ops from not_started); applicant_id wants the application id, not the customer id; /events?limit caps at 100.

What Dakota does not have

  • No token issuance. Nothing creates a stablecoin — the supported-asset catalog is ours, and it doubles as the server-side allow-list.
  • No assets endpoint. /capabilities/networks returns bare network strings.
  • No fee schedule for our tierGET /self-serve/credits/pricing 403s "Credit management is only available for self-serve customers." So the expected schedule is admin-entered and labelled as such, while realised rates come from transaction receipts and are shown beside it.

Staging-only, enforced four ways

deploy.sh filters the requested set against the env's compose file, so a service absent there can never be planned or health-gated — the same mechanism excluding cctp-relay and market-sim. For dakota-service this is by design: it integrates a sandbox with testnet custody, mocked banking and a $2 cap, so there is nothing useful it could do in prod.

docker-compose.prod.yml not declared (with a comment saying why)
nginx.prod.conf no route
config.prod.toml does not exist — the image exits on the missing file
options/prod/dakota-service no secret

Verification

Workspace build + clippy clean
Unit tests 62 passed (93 across the stack)
Live Dakota signing passes against the real sandbox
End-to-end smoke.sh 31/31
Dashboard + existing frontend typecheck clean
affected.py parity 20/20, correct fan-out

smoke.sh covers admin bootstrap, the hierarchy, cross-scope isolation (a business cannot read an outsider; a forged sub_client_id is refused), the approval gate, all three ramps, both allow-lists, the $2 cap, funding, the ledger, and webhook authenticity.

Worth knowing when reading the live signing test: an insufficient-balance rejection is success — it means the signature verified and Dakota reached policy evaluation.

Before deploying

options/staging/dakota-service in Secrets Manager, a dakota_staging database, and terraform apply for the ECR repo (a missing repo fails the push with a 403). Then register the webhook target once from the dashboard's Ops screen — nothing reaches the activity feed until that's done. Full list in docs/dakota-rollout.md.

Deferred

Sumsub import is designed for but not enabled: it needs a Dakota-issued sandbox partner token (from a Dakota rep, 30-day expiry) plus "Share applicants data" on our Sumsub app token. Individual applicants only, and imports land in draft missing SSN/attestations — so the hosted form is the only no-PII completion path either way. KYC ships hosted-redirect-only.

🤖 Generated with Claude Code

https://claude.ai/code/session_0152Pn9P1PWogKrSdsS1jmrr

@vercel

vercel Bot commented Aug 2, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
sui-options Ready Ready Preview Aug 2, 2026 11:41pm

Request Review

…staging-only)

Adds dakota-service fronting Dakota's sandbox and the dakota-dashboard console.
Stacked on SO-341, which reworks auth-service into the multi-method identity
service this depends on.

Verified end to end against the live sandbox: 62 unit tests, a live signing
test, and a 31-assertion smoke script.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0152Pn9P1PWogKrSdsS1jmrr
@ewitulsk
ewitulsk force-pushed the ewitulsk/dakota/sandbox-mvp branch from e5a7f9c to a18fdc4 Compare August 2, 2026 23:41
@ewitulsk
ewitulsk changed the base branch from staging to ewitulsk/auth/multi-method-identity August 2, 2026 23:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant