From a18fdc41a092d1fd5d84ab65ea66b170d09391a3 Mon Sep 17 00:00:00 2001 From: Evan Witulski Date: Sun, 2 Aug 2026 19:41:22 -0400 Subject: [PATCH] [SO-336] Add the Dakota stablecoin on/off-ramp integration (sandbox, 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) Claude-Session: https://claude.ai/code/session_0152Pn9P1PWogKrSdsS1jmrr --- dakota-dashboard/.env.example | 7 + dakota-dashboard/.gitignore | 7 + dakota-dashboard/README.md | 71 + dakota-dashboard/index.html | 12 + dakota-dashboard/package-lock.json | 3304 +++++++++++++++++ dakota-dashboard/package.json | 27 + dakota-dashboard/src/App.tsx | 126 + dakota-dashboard/src/api/auth.ts | 138 + dakota-dashboard/src/api/dakota.ts | 303 ++ .../src/components/ActivityTable.tsx | 125 + dakota-dashboard/src/components/RampForm.tsx | 256 ++ dakota-dashboard/src/components/ui.tsx | 110 + dakota-dashboard/src/config.ts | 16 + dakota-dashboard/src/main.tsx | 46 + dakota-dashboard/src/screens/Assets.tsx | 175 + dakota-dashboard/src/screens/Customers.tsx | 211 ++ dakota-dashboard/src/screens/Flows.tsx | 27 + dakota-dashboard/src/screens/Login.tsx | 119 + dakota-dashboard/src/screens/Ops.tsx | 84 + dakota-dashboard/src/screens/Ramps.tsx | 193 + dakota-dashboard/src/screens/Settings.tsx | 179 + dakota-dashboard/src/screens/Signup.tsx | 138 + dakota-dashboard/src/screens/Treasury.tsx | 183 + dakota-dashboard/src/state/session.tsx | 63 + dakota-dashboard/src/styles.css | 150 + dakota-dashboard/src/vite-env.d.ts | 10 + dakota-dashboard/tsconfig.json | 20 + dakota-dashboard/vercel.json | 3 + dakota-dashboard/vite.config.ts | 10 + docs/dakota-rollout.md | 247 ++ docs/dakota-sandbox-notes.md | 218 ++ rust-backend/Cargo.lock | 53 + rust-backend/Cargo.toml | 8 + rust-backend/Dockerfile.dakota-service | 27 + .../crates/runtime-config/src/secrets.rs | 30 + rust-backend/deployment/affected.py | 9 +- rust-backend/deployment/bake.hcl | 12 +- .../compose/docker-compose.prod.yml | 7 + .../compose/docker-compose.staging.yml | 23 + rust-backend/deployment/ec2/deploy.sh | 5 +- .../deployment/nginx/nginx.staging.conf | 13 + rust-backend/infra/ecr.tf | 5 +- .../services/dakota-service/Cargo.toml | 59 + .../dakota-service/config/config.staging.toml | 49 + .../dakota-service/config/config.toml | 42 + .../config/secrets.example.toml | 21 + rust-backend/services/dakota-service/smoke.sh | 178 + .../services/dakota-service/src/authz.rs | 234 ++ .../services/dakota-service/src/config.rs | 90 + .../dakota-service/src/dakota/client.rs | 247 ++ .../dakota-service/src/dakota/error.rs | 93 + .../services/dakota-service/src/dakota/mod.rs | 8 + .../dakota-service/src/dakota/types.rs | 329 ++ .../src/db/migrations/000001_init/down.sql | 7 + .../src/db/migrations/000001_init/up.sql | 134 + .../services/dakota-service/src/db/mod.rs | 30 + .../services/dakota-service/src/db/models.rs | 222 ++ .../services/dakota-service/src/db/repo.rs | 324 ++ .../services/dakota-service/src/db/schema.rs | 113 + .../dakota-service/src/handlers/accounts.rs | 326 ++ .../dakota-service/src/handlers/admin.rs | 346 ++ .../dakota-service/src/handlers/catalog.rs | 138 + .../dakota-service/src/handlers/customers.rs | 265 ++ .../dakota-service/src/handlers/flows.rs | 196 + .../dakota-service/src/handlers/mod.rs | 42 + .../dakota-service/src/handlers/wallets.rs | 427 +++ .../services/dakota-service/src/invites.rs | 67 + .../services/dakota-service/src/lib.rs | 83 + .../services/dakota-service/src/main.rs | 95 + .../services/dakota-service/src/router.rs | 116 + .../services/dakota-service/src/state.rs | 36 + .../dakota-service/src/wallet/live_tests.rs | 186 + .../services/dakota-service/src/wallet/mod.rs | 386 ++ .../services/dakota-service/src/webhook.rs | 556 +++ 74 files changed, 12211 insertions(+), 4 deletions(-) create mode 100644 dakota-dashboard/.env.example create mode 100644 dakota-dashboard/.gitignore create mode 100644 dakota-dashboard/README.md create mode 100644 dakota-dashboard/index.html create mode 100644 dakota-dashboard/package-lock.json create mode 100644 dakota-dashboard/package.json create mode 100644 dakota-dashboard/src/App.tsx create mode 100644 dakota-dashboard/src/api/auth.ts create mode 100644 dakota-dashboard/src/api/dakota.ts create mode 100644 dakota-dashboard/src/components/ActivityTable.tsx create mode 100644 dakota-dashboard/src/components/RampForm.tsx create mode 100644 dakota-dashboard/src/components/ui.tsx create mode 100644 dakota-dashboard/src/config.ts create mode 100644 dakota-dashboard/src/main.tsx create mode 100644 dakota-dashboard/src/screens/Assets.tsx create mode 100644 dakota-dashboard/src/screens/Customers.tsx create mode 100644 dakota-dashboard/src/screens/Flows.tsx create mode 100644 dakota-dashboard/src/screens/Login.tsx create mode 100644 dakota-dashboard/src/screens/Ops.tsx create mode 100644 dakota-dashboard/src/screens/Ramps.tsx create mode 100644 dakota-dashboard/src/screens/Settings.tsx create mode 100644 dakota-dashboard/src/screens/Signup.tsx create mode 100644 dakota-dashboard/src/screens/Treasury.tsx create mode 100644 dakota-dashboard/src/state/session.tsx create mode 100644 dakota-dashboard/src/styles.css create mode 100644 dakota-dashboard/src/vite-env.d.ts create mode 100644 dakota-dashboard/tsconfig.json create mode 100644 dakota-dashboard/vercel.json create mode 100644 dakota-dashboard/vite.config.ts create mode 100644 docs/dakota-rollout.md create mode 100644 docs/dakota-sandbox-notes.md create mode 100644 rust-backend/Dockerfile.dakota-service create mode 100644 rust-backend/services/dakota-service/Cargo.toml create mode 100644 rust-backend/services/dakota-service/config/config.staging.toml create mode 100644 rust-backend/services/dakota-service/config/config.toml create mode 100644 rust-backend/services/dakota-service/config/secrets.example.toml create mode 100755 rust-backend/services/dakota-service/smoke.sh create mode 100644 rust-backend/services/dakota-service/src/authz.rs create mode 100644 rust-backend/services/dakota-service/src/config.rs create mode 100644 rust-backend/services/dakota-service/src/dakota/client.rs create mode 100644 rust-backend/services/dakota-service/src/dakota/error.rs create mode 100644 rust-backend/services/dakota-service/src/dakota/mod.rs create mode 100644 rust-backend/services/dakota-service/src/dakota/types.rs create mode 100644 rust-backend/services/dakota-service/src/db/migrations/000001_init/down.sql create mode 100644 rust-backend/services/dakota-service/src/db/migrations/000001_init/up.sql create mode 100644 rust-backend/services/dakota-service/src/db/mod.rs create mode 100644 rust-backend/services/dakota-service/src/db/models.rs create mode 100644 rust-backend/services/dakota-service/src/db/repo.rs create mode 100644 rust-backend/services/dakota-service/src/db/schema.rs create mode 100644 rust-backend/services/dakota-service/src/handlers/accounts.rs create mode 100644 rust-backend/services/dakota-service/src/handlers/admin.rs create mode 100644 rust-backend/services/dakota-service/src/handlers/catalog.rs create mode 100644 rust-backend/services/dakota-service/src/handlers/customers.rs create mode 100644 rust-backend/services/dakota-service/src/handlers/flows.rs create mode 100644 rust-backend/services/dakota-service/src/handlers/mod.rs create mode 100644 rust-backend/services/dakota-service/src/handlers/wallets.rs create mode 100644 rust-backend/services/dakota-service/src/invites.rs create mode 100644 rust-backend/services/dakota-service/src/lib.rs create mode 100644 rust-backend/services/dakota-service/src/main.rs create mode 100644 rust-backend/services/dakota-service/src/router.rs create mode 100644 rust-backend/services/dakota-service/src/state.rs create mode 100644 rust-backend/services/dakota-service/src/wallet/live_tests.rs create mode 100644 rust-backend/services/dakota-service/src/wallet/mod.rs create mode 100644 rust-backend/services/dakota-service/src/webhook.rs 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 @@ + + + + + + Dakota Console + + +
+ + + diff --git a/dakota-dashboard/package-lock.json b/dakota-dashboard/package-lock.json new file mode 100644 index 00000000..0b557f9a --- /dev/null +++ b/dakota-dashboard/package-lock.json @@ -0,0 +1,3304 @@ +{ + "name": "dakota-dashboard", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "dakota-dashboard", + "version": "0.0.1", + "dependencies": { + "@mysten/dapp-kit": "^1.0.6", + "@mysten/sui": "^2.17.0", + "@tanstack/react-query": "^5.100.11", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^7.15.1" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.3", + "typescript": "^5.6.3", + "vite": "^5.4.10" + } + }, + "node_modules/@0no-co/graphql.web": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@0no-co/graphql.web/-/graphql.web-1.3.3.tgz", + "integrity": "sha512-4gFGBdyaFmQ6n9euhp5JtIGS4ZeivwDr1tCPENUxTvy5wyv532yOtFCr9zzYAJh1s6uibgC+TRXUcay+mxzCoQ==", + "license": "MIT", + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0" + }, + "peerDependenciesMeta": { + "graphql": { + "optional": true + } + } + }, + "node_modules/@0no-co/graphqlsp": { + "version": "1.17.3", + "resolved": "https://registry.npmjs.org/@0no-co/graphqlsp/-/graphqlsp-1.17.3.tgz", + "integrity": "sha512-4PPvxDPmbntddpgMyA3VId5/E9YGdRuuS/mW+THOvtTx/C79Pf+lN28LkNNACJrF9L7YACiAJelyOkC6LqUzvw==", + "license": "MIT", + "dependencies": { + "@gql.tada/internal": "^1.2.0", + "graphql": "^15.5.0 || ^16.0.0 || ^17.0.0" + }, + "peerDependencies": { + "graphql": "^15.5.0 || ^16.0.0 || ^17.0.0", + "typescript": "^5.0.0 || ^6.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "license": "MIT" + }, + "node_modules/@gql.tada/cli-utils": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/@gql.tada/cli-utils/-/cli-utils-1.9.3.tgz", + "integrity": "sha512-P1TiXErpJwIi73sei5fzwGA/SOeCaIHFWFR4RdZPLwqxZzQN0T6MAUivzXBgKCORL67rvYnLaaPoWeqWq/61ug==", + "license": "MIT", + "dependencies": { + "@0no-co/graphqlsp": "^1.17.3", + "@gql.tada/internal": "1.2.2", + "graphql": "^15.5.0 || ^16.0.0 || ^17.0.0" + }, + "peerDependencies": { + "@0no-co/graphqlsp": "^1.16.0", + "@gql.tada/svelte-support": "1.0.3", + "@gql.tada/vue-support": "1.0.3", + "graphql": "^15.5.0 || ^16.0.0 || ^17.0.0", + "typescript": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@gql.tada/svelte-support": { + "optional": true + }, + "@gql.tada/vue-support": { + "optional": true + } + } + }, + "node_modules/@gql.tada/internal": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@gql.tada/internal/-/internal-1.2.2.tgz", + "integrity": "sha512-4lZcElPP6MC8Ct8KN70LR2WQsHjbAsyAmTGG09VsOPhx738UFIYaL0S1XiI2pqq2tj/sDs/y3b9jvKoWGL7iuQ==", + "license": "MIT", + "dependencies": { + "@0no-co/graphql.web": "^1.3.1" + }, + "peerDependencies": { + "graphql": "^15.5.0 || ^16.0.0 || ^17.0.0", + "typescript": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/@graphql-typed-document-node/core": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz", + "integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==", + "license": "MIT", + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mysten/bcs": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@mysten/bcs/-/bcs-2.1.0.tgz", + "integrity": "sha512-rIR/cDAqDfBDxmYEZXppN/on8gmh1OW0dYi/Bk2kMBZcYMTaBaEtEX1VR6g7HicVxuMbFAIP0/SQkdH7J9Y5dQ==", + "license": "Apache-2.0", + "dependencies": { + "@mysten/utils": "^0.4.0", + "@scure/base": "^2.2.0" + } + }, + "node_modules/@mysten/dapp-kit": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@mysten/dapp-kit/-/dapp-kit-1.1.12.tgz", + "integrity": "sha512-npWtqE3Xz6qpeeJQhql36DGSxR4r9VkilOlVkn234wyIFlQsD2KqiAGNZaRrZYPSuiOvvlvX4aSkNekmhJksTw==", + "license": "Apache-2.0", + "dependencies": { + "@mysten/slush-wallet": "^1.1.12", + "@mysten/utils": "^0.4.0", + "@mysten/wallet-standard": "^0.21.12", + "@radix-ui/react-dialog": "^1.1.16", + "@radix-ui/react-dropdown-menu": "^2.1.17", + "@radix-ui/react-slot": "^1.2.5", + "@vanilla-extract/css": "^1.20.1", + "@vanilla-extract/dynamic": "^2.1.5", + "@vanilla-extract/recipes": "^0.5.7", + "clsx": "^2.1.1", + "zustand": "^5.0.14" + }, + "peerDependencies": { + "@mysten/sui": "^2.23.1", + "@tanstack/react-query": "^5.0.0", + "react": "*" + } + }, + "node_modules/@mysten/dapp-kit/node_modules/@radix-ui/react-dialog": { + "version": "1.1.23", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.23.tgz", + "integrity": "sha512-Ksw4WeROkO4rC9k/onilX/Ao2Cr1ku1unMNH+XSCcP4jSXYu7HDsg9n4ojMjVb22XpYjAQ9qfrFlVbru1vXDUA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@mysten/dapp-kit/node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.19.tgz", + "integrity": "sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-effect-event": "0.0.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@mysten/dapp-kit/node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.16.tgz", + "integrity": "sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@mysten/dapp-kit/node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-portal": { + "version": "1.1.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.17.tgz", + "integrity": "sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@mysten/dapp-kit/node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-presence": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.10.tgz", + "integrity": "sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@mysten/dapp-kit/node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-primitive": { + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.10.tgz", + "integrity": "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.3.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@mysten/dapp-kit/node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.24", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.24.tgz", + "integrity": "sha512-geq8l2rJkxvkXsT9RMgtUE3P8pITFpTsvYpbySi1IH4fZEABD/Gp85myayFgxk0ktljGMJnCbeFkyTusvSvv7g==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-menu": "2.1.24", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@mysten/dapp-kit/node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-menu": { + "version": "2.1.24", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.24.tgz", + "integrity": "sha512-uW7RVuU6Lp/ZtfeY4b3kL32zccgEWvPv1+cf17ubYzHa9cL8AHokmk36cG/XEiH/smbQvumnieXX9j/e9RqJWA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-callback-ref": "1.1.4", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@mysten/dapp-kit/node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-collection": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.15.tgz", + "integrity": "sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@mysten/dapp-kit/node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.19.tgz", + "integrity": "sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-effect-event": "0.0.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@mysten/dapp-kit/node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.16.tgz", + "integrity": "sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@mysten/dapp-kit/node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-popper": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.7.tgz", + "integrity": "sha512-UsJrrd7w4wuKKTdvd/DNERVlwSlUcyXzjhyDwBk+3aPOsCjOY6ZSbxuw8E6lZTjjfP8Cpd0J8VVkrYUWyGYXyg==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-rect": "1.1.4", + "@radix-ui/react-use-size": "1.1.4", + "@radix-ui/rect": "1.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@mysten/dapp-kit/node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-popper/node_modules/@floating-ui/react-dom": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz", + "integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.8.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@mysten/dapp-kit/node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-arrow": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.15.tgz", + "integrity": "sha512-v4zggRcjadnI+ClKDuijlQEW4tw3NoaeHc/PwpKnLoLLKNUG4InLegkstooLcRIUWCs+8L22dGURCVuFfOKfnA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@mysten/dapp-kit/node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-portal": { + "version": "1.1.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.17.tgz", + "integrity": "sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@mysten/dapp-kit/node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-presence": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.10.tgz", + "integrity": "sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@mysten/dapp-kit/node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.19.tgz", + "integrity": "sha512-V9jI6hDjT7l3jsCQD9bLNvDLM3tH/gdbOTp7Tefp3hbbgCGQoK7tUvrWiRlcoBHIZ809ElXwNQwVo0B98LuTXQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-is-hydrated": "0.1.3", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@mysten/dapp-kit/node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-primitive": { + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.10.tgz", + "integrity": "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.3.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@mysten/slush-wallet": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@mysten/slush-wallet/-/slush-wallet-1.1.12.tgz", + "integrity": "sha512-YZepAhwiPRnCYTLzGimuikol9cg2GTr0pN8giCVhBU64QvISz83ADhMHvdSatV+fFcfU/gzz3MZT+YXqRm0p5g==", + "license": "Apache-2.0", + "dependencies": { + "@mysten/utils": "^0.4.0", + "@mysten/wallet-standard": "^0.21.12", + "@mysten/window-wallet-core": "^0.2.1", + "valibot": "^1.4.2" + }, + "peerDependencies": { + "@mysten/sui": "^2.23.1" + } + }, + "node_modules/@mysten/sui": { + "version": "2.23.1", + "resolved": "https://registry.npmjs.org/@mysten/sui/-/sui-2.23.1.tgz", + "integrity": "sha512-9MF+MsQWQWm6TFhUfkddigT2/aMv6t4uoOiW8aft3DFYh8k+TjErSJWKIlqKZZKO+R1paXg09qM3K0ZlOMbMfQ==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@graphql-typed-document-node/core": "^3.2.0", + "@mysten/bcs": "^2.1.0", + "@mysten/utils": "^0.4.0", + "@noble/curves": "^2.2.0", + "@noble/hashes": "^2.2.0", + "@protobuf-ts/grpcweb-transport": "^2.11.1", + "@protobuf-ts/runtime": "^2.11.1", + "@protobuf-ts/runtime-rpc": "^2.11.1", + "@scure/base": "^2.2.0", + "@scure/bip32": "^2.2.0", + "@scure/bip39": "^2.2.0", + "gql.tada": "^1.10.1", + "graphql": "^16.14.2", + "poseidon-lite": "0.2.1", + "valibot": "^1.4.2" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@mysten/utils": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@mysten/utils/-/utils-0.4.0.tgz", + "integrity": "sha512-nHlXECBl9kE+AHF/aw5a7JVNizae8Kb71A4H18BV/sXd5/l2BaWNGWVatq05fzJo3hF78AWorDxa++1TgcBKWw==", + "license": "Apache-2.0", + "dependencies": { + "@scure/base": "^2.2.0" + } + }, + "node_modules/@mysten/wallet-standard": { + "version": "0.21.12", + "resolved": "https://registry.npmjs.org/@mysten/wallet-standard/-/wallet-standard-0.21.12.tgz", + "integrity": "sha512-fF70b4VrE4764dTBOXsL1d2KQQymPaxNxESZ8NIT3pO8UW1HxsibAshfzrCs4Vf4J/E7hn/MVd8QatlbiOWaBA==", + "license": "Apache-2.0", + "dependencies": { + "@wallet-standard/core": "1.1.2" + }, + "peerDependencies": { + "@mysten/sui": "^2.23.1" + } + }, + "node_modules/@mysten/window-wallet-core": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@mysten/window-wallet-core/-/window-wallet-core-0.2.1.tgz", + "integrity": "sha512-gWOVvbi9TjpeDorLgcjavhf2KPiQTKFnbs6j8bKxxE88+lur6n59pnp7BnqmfeKI+c8bOGYI2SRjj97UfK68ew==", + "license": "Apache-2.0", + "dependencies": { + "@mysten/utils": "^0.4.0", + "jose": "^6.2.3", + "valibot": "^1.4.2" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@noble/curves": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.2.0.tgz", + "integrity": "sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.2.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@protobuf-ts/grpcweb-transport": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@protobuf-ts/grpcweb-transport/-/grpcweb-transport-2.11.1.tgz", + "integrity": "sha512-1W4utDdvOB+RHMFQ0soL4JdnxjXV+ddeGIUg08DvZrA8Ms6k5NN6GBFU2oHZdTOcJVpPrDJ02RJlqtaoCMNBtw==", + "license": "Apache-2.0", + "dependencies": { + "@protobuf-ts/runtime": "^2.11.1", + "@protobuf-ts/runtime-rpc": "^2.11.1" + } + }, + "node_modules/@protobuf-ts/runtime": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@protobuf-ts/runtime/-/runtime-2.11.1.tgz", + "integrity": "sha512-KuDaT1IfHkugM2pyz+FwiY80ejWrkH1pAtOBOZFuR6SXEFTsnb/jiQWQ1rCIrcKx2BtyxnxW6BWwsVSA/Ie+WQ==", + "license": "(Apache-2.0 AND BSD-3-Clause)" + }, + "node_modules/@protobuf-ts/runtime-rpc": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@protobuf-ts/runtime-rpc/-/runtime-rpc-2.11.1.tgz", + "integrity": "sha512-4CqqUmNA+/uMz00+d3CYKgElXO9VrEbucjnBFEjqI4GuDrEQ32MaI3q+9qPBvIGOlL4PmHXrzM32vBPWRhQKWQ==", + "license": "Apache-2.0", + "dependencies": { + "@protobuf-ts/runtime": "^2.11.1" + } + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.7.tgz", + "integrity": "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz", + "integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.2.tgz", + "integrity": "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.4.tgz", + "integrity": "sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.6.tgz", + "integrity": "sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.4.tgz", + "integrity": "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.3.tgz", + "integrity": "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.4.tgz", + "integrity": "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.6.tgz", + "integrity": "sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-use-effect-event": "0.0.5", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.5.tgz", + "integrity": "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-is-hydrated": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.3.tgz", + "integrity": "sha512-umO/aJ+82CpOnhDZUTbILCQf7kU/g0iv+oGs/Q8jw7IkhWBzaEP4sA268PhFAJTFetbwp3ICc6ktpI4TqtxcIw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz", + "integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.4.tgz", + "integrity": "sha512-cSOCh6JlkmfjLyNcLiu2nB4v+nm+dkZ+Q5KHWk/soo4U7ZLiEQFKHK9/YmtBHjfCEaU43IBKQOc4/uJmCaiCTQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.4.tgz", + "integrity": "sha512-D3anSY15EJoxrihpsXI6SMrmmonnQtR2ni7arO+Lfdg3O95b9hNXxONk8jA5C8ANdF/h5HMAxejgs8PWJ6rlhw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.3.tgz", + "integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==", + "license": "MIT" + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@scure/base": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz", + "integrity": "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.2.0.tgz", + "integrity": "sha512-zFr7t2F+a9+5tB7QbarF2HQNYrgjCNaoLAupZdKkrFMYMozJf5zqH2WJCQibMzm1qQ0QogrxVGO3qXfQDYMaQg==", + "license": "MIT", + "dependencies": { + "@noble/curves": "2.2.0", + "@noble/hashes": "2.2.0", + "@scure/base": "2.2.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.2.0.tgz", + "integrity": "sha512-T/Bj/YvYMNkIPq6EENO6/rcs2e7qTNuyoUXf0KBFDmp0ZDu0H2X4Lq6yC3i0c8PcWkov5EbW+yQZZbdMmk154A==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.2.0", + "@scure/base": "2.2.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.4.tgz", + "integrity": "sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.4.tgz", + "integrity": "sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@tanstack/query-core": "5.101.4" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "devOptional": true, + "license": "MIT", + "peer": true, + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vanilla-extract/css": { + "version": "1.21.2", + "resolved": "https://registry.npmjs.org/@vanilla-extract/css/-/css-1.21.2.tgz", + "integrity": "sha512-ehF/tmv2MxQwOJB1DicALUqnjZTnmY9Y7J2ccKB578JzZpYeL6sLAUqbaXo1taw1Erp0qBq0I4Kejs0uU/pBfg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@emotion/hash": "^0.9.0", + "@vanilla-extract/private": "^1.0.9", + "css-what": "^6.1.0", + "csstype": "^3.2.3", + "dedent": "^1.5.3", + "deep-object-diff": "^1.1.9", + "deepmerge": "^4.2.2", + "lru-cache": "^10.4.3", + "media-query-parser": "^2.0.2", + "modern-ahocorasick": "^1.0.0", + "picocolors": "^1.0.0" + } + }, + "node_modules/@vanilla-extract/dynamic": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@vanilla-extract/dynamic/-/dynamic-2.1.5.tgz", + "integrity": "sha512-QGIFGb1qyXQkbzx6X6i3+3LMc/iv/ZMBttMBL+Wm/DetQd36KsKsFg5CtH3qy+1hCA/5w93mEIIAiL4fkM8ycw==", + "license": "MIT", + "dependencies": { + "@vanilla-extract/private": "^1.0.9" + } + }, + "node_modules/@vanilla-extract/private": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@vanilla-extract/private/-/private-1.0.9.tgz", + "integrity": "sha512-gT2jbfZuaaCLrAxwXbRgIhGhcXbRZCG3v4TTUnjw0EJ7ArdBRxkq4msNJkbuRkCgfIK5ATmprB5t9ljvLeFDEA==", + "license": "MIT" + }, + "node_modules/@vanilla-extract/recipes": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/@vanilla-extract/recipes/-/recipes-0.5.7.tgz", + "integrity": "sha512-Fvr+htdyb6LVUu+PhH61UFPhwkjgDEk8L4Zq9oIdte42sntpKrgFy90MyTRtGwjVALmrJ0pwRUVr8UoByYeW8A==", + "license": "MIT", + "peerDependencies": { + "@vanilla-extract/css": "^1.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@wallet-standard/app": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@wallet-standard/app/-/app-1.1.1.tgz", + "integrity": "sha512-WDGwoByhP5gwHH01r5EaLgQdLVkACPCdOMQhmhn8rsm10h/siSgTorShzBxrn0ExSPof+Lu+C3TfgqBrPa1xoQ==", + "license": "Apache-2.0", + "dependencies": { + "@wallet-standard/base": "^1.1.1" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@wallet-standard/base": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@wallet-standard/base/-/base-1.1.1.tgz", + "integrity": "sha512-gggIHTtxicF9XFMQ12DkfS6NAG92Ak795JeSA7f2whAQ6Y3AkMWWuCMxSZXG2NIPN42kEaZSNVjqMsJRaJRxMQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=22" + } + }, + "node_modules/@wallet-standard/core": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@wallet-standard/core/-/core-1.1.2.tgz", + "integrity": "sha512-QcVLGDkFtsWjTpkej2jx4FyP2cu+qOAW/lVnvlWjyhCkSEje6z+vEKURV5v+7L6IXjbze5pyFBe24yrPyoUuyw==", + "license": "Apache-2.0", + "dependencies": { + "@wallet-standard/app": "^1.1.1", + "@wallet-standard/base": "^1.1.1", + "@wallet-standard/errors": "^0.1.2", + "@wallet-standard/features": "^1.1.1", + "@wallet-standard/wallet": "^1.1.1" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@wallet-standard/errors": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@wallet-standard/errors/-/errors-0.1.2.tgz", + "integrity": "sha512-oEzKUqJefKby6wcIvaJgrSEe/uNn/rnqkJ0P/85K+h0i5Tdo9E3L22VWq/j5K1e8hHMnZd6LgaIr8m/Wn7X/Ng==", + "license": "Apache-2.0", + "dependencies": { + "chalk": "^5.4.1", + "commander": "^13.1.0" + }, + "bin": { + "errors": "bin/cli.mjs" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@wallet-standard/features": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@wallet-standard/features/-/features-1.1.1.tgz", + "integrity": "sha512-aCWYmVeSCGViyEU5k7GMoW8zxE4Gs+C1s1Pp2XLesvSNlnZ4PMES9HUnTB3hl0b3RVj7C61yze3IWyrncqg4MA==", + "license": "Apache-2.0", + "dependencies": { + "@wallet-standard/base": "^1.1.1" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@wallet-standard/wallet": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@wallet-standard/wallet/-/wallet-1.1.1.tgz", + "integrity": "sha512-8WiRPaKk/wNNRZhB2eVhpR/JW7/aqTCMoZhgVUCujuzDmxxmGvsosMxdCG4NAdYkoyozAHCX8/xLtlWUn5mNdQ==", + "license": "Apache-2.0", + "dependencies": { + "@wallet-standard/base": "^1.1.1" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.11", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.11.tgz", + "integrity": "sha512-/yImnXwyTvgMkhgekLHok/Rx5vO6E0BmStWlSqKWMVm2a2ITuZ1Tn+9bgLS+gZRdZmWtd8nxuhHpdmCUOWsTQQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.7", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", + "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.10.44", + "caniuse-lite": "^1.0.30001806", + "electron-to-chromium": "^1.5.393", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/commander": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", + "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dedent": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", + "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/deep-object-diff": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/deep-object-diff/-/deep-object-diff-1.1.9.tgz", + "integrity": "sha512-Rn+RuwkmkDwCi2/oXOFS9Gsr5lJZu/yTGpK7wAaAIE75CC+LCGEZHpY6VQJa/RoJcrmaA/docWJZvYohlNkWPA==", + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.399", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.399.tgz", + "integrity": "sha512-lEcqhErbHjXRvd41rnWLpzbyU/IXfIYo7QwaFWmxGeLiLyY2TBCdHnWY88vB+p3ubnihRypDm66panXl7TylLA==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/gql.tada": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/gql.tada/-/gql.tada-1.11.3.tgz", + "integrity": "sha512-5JCI4j2f0nug8ILaCQys/yjOP78QqqjVUf47OQsME63rZfemsHT3e5vcfbHsnZiG6vxyqpKUJArtXEVwSefUhw==", + "license": "MIT", + "dependencies": { + "@0no-co/graphql.web": "^1.3.2", + "@0no-co/graphqlsp": "^1.17.3", + "@gql.tada/cli-utils": "1.9.3", + "@gql.tada/internal": "1.2.2" + }, + "bin": { + "gql-tada": "bin/cli.js", + "gql.tada": "bin/cli.js" + }, + "peerDependencies": { + "typescript": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/graphql": { + "version": "16.14.2", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.14.2.tgz", + "integrity": "sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==", + "license": "MIT", + "peer": true, + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, + "node_modules/jose": { + "version": "6.2.7", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.7.tgz", + "integrity": "sha512-hq1OB1bALKfydZNoViyg6hPVGV4i93ny9Op+n4zP5RSf7SCZEXa/TsG2O3IEr7+WlHRTPnpqDmHfMH6qXAD60w==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/media-query-parser": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/media-query-parser/-/media-query-parser-2.0.2.tgz", + "integrity": "sha512-1N4qp+jE0pL5Xv4uEcwVUhIkwdUO3S/9gML90nqKA7v7FcOS5vUtatfzok9S9U1EJU8dHWlcv95WLnKmmxZI9w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + } + }, + "node_modules/modern-ahocorasick": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/modern-ahocorasick/-/modern-ahocorasick-1.1.0.tgz", + "integrity": "sha512-sEKPVl2rM+MNVkGQt3ChdmD8YsigmXdn5NifZn6jiwn9LRJpWm8F3guhaqrJT/JOat6pwpbXEk6kv+b9DMIjsQ==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/poseidon-lite": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/poseidon-lite/-/poseidon-lite-0.2.1.tgz", + "integrity": "sha512-xIr+G6HeYfOhCuswdqcFpSX47SPhm0EpisWJ6h7fHlWwaVIvH3dLnejpatrtw6Xc6HaLrpq05y7VRfvDmDGIog==", + "license": "MIT" + }, + "node_modules/postcss": { + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-router": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz", + "integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz", + "integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/rollup": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.4", + "@rollup/rollup-android-arm64": "4.62.4", + "@rollup/rollup-darwin-arm64": "4.62.4", + "@rollup/rollup-darwin-x64": "4.62.4", + "@rollup/rollup-freebsd-arm64": "4.62.4", + "@rollup/rollup-freebsd-x64": "4.62.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", + "@rollup/rollup-linux-arm-musleabihf": "4.62.4", + "@rollup/rollup-linux-arm64-gnu": "4.62.4", + "@rollup/rollup-linux-arm64-musl": "4.62.4", + "@rollup/rollup-linux-loong64-gnu": "4.62.4", + "@rollup/rollup-linux-loong64-musl": "4.62.4", + "@rollup/rollup-linux-ppc64-gnu": "4.62.4", + "@rollup/rollup-linux-ppc64-musl": "4.62.4", + "@rollup/rollup-linux-riscv64-gnu": "4.62.4", + "@rollup/rollup-linux-riscv64-musl": "4.62.4", + "@rollup/rollup-linux-s390x-gnu": "4.62.4", + "@rollup/rollup-linux-x64-gnu": "4.62.4", + "@rollup/rollup-linux-x64-musl": "4.62.4", + "@rollup/rollup-openbsd-x64": "4.62.4", + "@rollup/rollup-openharmony-arm64": "4.62.4", + "@rollup/rollup-win32-arm64-msvc": "4.62.4", + "@rollup/rollup-win32-ia32-msvc": "4.62.4", + "@rollup/rollup-win32-x64-gnu": "4.62.4", + "@rollup/rollup-win32-x64-msvc": "4.62.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/valibot": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.4.2.tgz", + "integrity": "sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==", + "license": "MIT", + "peerDependencies": { + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/zustand": { + "version": "5.0.14", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz", + "integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/dakota-dashboard/package.json b/dakota-dashboard/package.json new file mode 100644 index 00000000..f7310b79 --- /dev/null +++ b/dakota-dashboard/package.json @@ -0,0 +1,27 @@ +{ + "name": "dakota-dashboard", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "typecheck": "tsc -b --noEmit" + }, + "dependencies": { + "@mysten/dapp-kit": "^1.0.6", + "@mysten/sui": "^2.17.0", + "@tanstack/react-query": "^5.100.11", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^7.15.1" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.3", + "typescript": "^5.6.3", + "vite": "^5.4.10" + } +} diff --git a/dakota-dashboard/src/App.tsx b/dakota-dashboard/src/App.tsx new file mode 100644 index 00000000..a9e233a0 --- /dev/null +++ b/dakota-dashboard/src/App.tsx @@ -0,0 +1,126 @@ +import { Navigate, NavLink, Outlet, Route, Routes, useLocation } from "react-router-dom"; + +import Assets from "./screens/Assets"; +import Customers from "./screens/Customers"; +import Flows from "./screens/Flows"; +import Login from "./screens/Login"; +import Ops from "./screens/Ops"; +import Ramps from "./screens/Ramps"; +import Settings from "./screens/Settings"; +import Signup from "./screens/Signup"; +import Treasury from "./screens/Treasury"; +import { homeFor, useSession } from "./state/session"; +import type { Role } from "./api/auth"; + +/** Which routes exist for a role. + * + * Roles are not URL prefixes to be guessed at — the JWT decides, and + * dakota-service enforces the same boundary server-side. This map only + * controls what gets rendered and linked. */ +const NAV: Record> = { + admin: [ + { to: "/admin/flows", label: "Flows" }, + { to: "/admin/customers", label: "Customers" }, + { to: "/admin/ramps", label: "Ramps" }, + { to: "/admin/assets", label: "Assets & rates" }, + { to: "/admin/treasury", label: "Treasury" }, + { to: "/admin/ops", label: "Ops" }, + { to: "/settings", label: "Security" }, + ], + business: [ + { to: "/business/flows", label: "Flows" }, + { to: "/business/customers", label: "My customers" }, + { to: "/business/ramps", label: "Ramps" }, + { to: "/settings", label: "Security" }, + ], + individual: [ + { to: "/customer/flows", label: "My activity" }, + { to: "/customer/ramps", label: "Ramps" }, + { to: "/settings", label: "Security" }, + ], +}; + +const TITLE: Record = { + admin: "Dakota Console", + business: "Partner Console", + individual: "Dakota", +}; + +function Shell() { + const { session } = useSession(); + const location = useLocation(); + + if (!session) { + return ; + } + + return ( +
+ +
+ +
+
+ ); +} + +/** 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. + ) : ( + + + + + + + + } + > + {totals.map((t) => ( + + + + + + + + ))} +
AssetInOutNetEvents
{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. + ) : ( + + + + + + + + + } + > + {flows.map((f, i) => ( + onSelect?.(f.dakota_customer_id)} + style={onSelect ? { cursor: "pointer" } : undefined} + > + + + + + + + + ))} +
CustomerTypeAssetInOutEvents
{shortId(f.dakota_customer_id)}{f.customer_type}{f.asset ?? no activity}{formatMinor(f.inbound_minor)}{formatMinor(f.outbound_minor)}{f.events}
+ )} +
+ ); +} + +export function EventFeed({ events, title = "Activity" }: { events: LedgerEvent[]; title?: string }) { + return ( + + {events.length === 0 ? ( + Nothing recorded yet. Webhooks populate this as transfers settle. + ) : ( + + + + + + + + + + + } + > + {events.map((e) => ( + + + + + + + + + + + ))} +
WhenEventCustomerDirAmountAssetRateStatus
{fmtTime(e.occurred_at)}{e.event_type}{shortId(e.dakota_customer_id)}{e.direction ?? "—"}{formatMinor(e.amount_minor)}{e.asset ?? "—"}{e.exchange_rate ?? "—"} + +
+ )} +
+ ); +} diff --git a/dakota-dashboard/src/components/RampForm.tsx b/dakota-dashboard/src/components/RampForm.tsx new file mode 100644 index 00000000..851f4b1e --- /dev/null +++ b/dakota-dashboard/src/components/RampForm.tsx @@ -0,0 +1,256 @@ +import { useMemo, useState } from "react"; + +import * as api from "../api/dakota"; +import type { Asset, Catalog, Customer } from "../api/dakota"; +import { SANDBOX_MAX_AMOUNT } from "../config"; +import { CopyField, ErrorBox, Panel } from "./ui"; + +type Flow = "onramp" | "offramp" | "swap"; + +const BLURB: Record = { + onramp: + "USD in, stablecoin out. Dakota returns real ACH and Fedwire details; wire USD there and the stablecoin lands at your destination address.", + offramp: + "Stablecoin in, USD out. Dakota returns a deposit address; send the stablecoin there and Dakota wires the dollars to the bank account.", + swap: "Stablecoin in, stablecoin out — across chains. Fully on-chain in both directions.", +}; + +/** The ramp UI, shared by all three roles. + * + * It walks the whole prerequisite chain — recipient, destination, account — + * because the Dakota API will not tell you what is missing until it rejects + * you, and each step's id feeds the next. */ +export default function RampForm({ + token, + catalog, + customers, + isAdmin, +}: { + token: string; + catalog: Catalog; + customers: Customer[]; + isAdmin: boolean; +}) { + const [flow, setFlow] = useState("onramp"); + const [customerId, setCustomerId] = useState(customers[0]?.dakota_customer_id ?? ""); + const [assetKey, setAssetKey] = useState(""); + const [cryptoAddress, setCryptoAddress] = useState(""); + const [recipientName, setRecipientName] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [result, setResult] = useState | null>(null); + + const enabled = useMemo( + () => + catalog.assets.filter( + (a) => + (flow === "onramp" && a.onramp_enabled) || + (flow === "offramp" && a.offramp_enabled) || + (flow === "swap" && a.swap_enabled), + ), + [catalog.assets, flow], + ); + + const selected: Asset | undefined = enabled.find( + (a) => `${a.symbol}@${a.network_id}` === assetKey, + ); + const customer = customers.find((c) => c.dakota_customer_id === customerId); + const approved = customer?.kyb_status === "active"; + + const submit = async () => { + if (!selected || !customerId) return; + setBusy(true); + setError(null); + setResult(null); + try { + // 1. Recipient. Crypto-only recipients need no address; a fiat + // destination would, which is why offramp asks for more below. + const recipient = await api.createRecipient(token, customerId, { + name: recipientName || "Console recipient", + }); + + // 2. Destination. + const destination = await api.createDestination(token, recipient.id, { + customer_id: customerId, + destination_type: "crypto", + name: `${selected.symbol} on ${selected.network_id}`, + crypto_address: cryptoAddress, + network_id: selected.network_id, + }); + + // 3. Account. `capabilities` is filled in server-side for onramps — + // Dakota requires it and does not document that. + const body: api.CreateAccountBody = + flow === "onramp" + ? { + customer_id: customerId, + account_type: "onramp", + crypto_destination_id: destination.id, + destination_network_id: selected.network_id, + source_asset: "USD", + destination_asset: selected.symbol, + } + : flow === "swap" + ? { + customer_id: customerId, + account_type: "swap", + crypto_destination_id: destination.id, + destination_network_id: selected.network_id, + source_network_id: selected.network_id, + source_asset: selected.symbol, + destination_asset: selected.symbol, + } + : { + customer_id: customerId, + account_type: "offramp", + crypto_destination_id: destination.id, + source_network_id: selected.network_id, + source_asset: selected.symbol, + destination_asset: "USD", + }; + + setResult(await api.createAccount(token, body)); + } catch (e) { + setError(e); + } finally { + setBusy(false); + } + }; + + return ( + <> +
+ {(["onramp", "offramp", "swap"] as Flow[]).map((f) => ( + + ))} +
+ + + + + {customers.length === 0 ? ( +

No customers yet. Create one first.

+ ) : ( + <> +
+ + +
+ + {enabled.length === 0 && ( +

+ 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. +

+ + ) : result.source_crypto_address ? ( + <> + +

+ 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 ( + <> +

Assets & rates

+ + + +
+ + +
+
+ {(["onramp", "offramp", "swap"] as const).map((f) => ( + + ))} + +
+

+ Only testnets are offered: the sandbox lists mainnet ids and then refuses them. +

+
+ + + {catalog.data?.assets.length ? ( + + + + + + + + + } + > + {catalog.data.assets.map((a) => ( + + + + + + + + + ))} +
AssetNetworkOnrampOfframpSwap
{a.symbol}{a.network_id}{a.onramp_enabled ? "yes" : "—"}{a.offramp_enabled ? "yes" : "—"}{a.swap_enabled ? "yes" : "—"} + +
+ ) : ( + No assets yet. Add one above — ramps cannot run without it. + )} +
+ + + {rates.data?.schedule ? ( +

+ Transfer {rates.data.schedule.transfer_fee_bps ?? "—"} bps · ACH{" "} + {rates.data.schedule.ach_fee_cents ?? "—"}¢ · Wire{" "} + {rates.data.schedule.wire_fee_cents ?? "—"}¢{" "} + source: {rates.data.schedule.source} +

+ ) : ( +

No expected schedule recorded.

+ )} + + {rates.data?.realised.length ? ( + + + + + + + } + > + {rates.data.realised.slice(0, 20).map((r, i) => ( + + + + + + + ))} +
AssetRateAmountDakota fee
{r.asset ?? "—"}{r.exchange_rate ?? "—"}{api.formatMinor(r.amount_minor)}{api.formatMinor(r.fee_minor)}
+ ) : ( +

No settled transactions yet, so no realised rates.

+ )} +
+ + ); +} diff --git a/dakota-dashboard/src/screens/Customers.tsx b/dakota-dashboard/src/screens/Customers.tsx new file mode 100644 index 00000000..05d67bae --- /dev/null +++ b/dakota-dashboard/src/screens/Customers.tsx @@ -0,0 +1,211 @@ +import { useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; + +import * as api from "../api/dakota"; +import type { Customer } from "../api/dakota"; +import { CopyField, Empty, ErrorBox, Panel, StatusPill, Table, fmtTime, shortId } from "../components/ui"; +import { useAuthed } from "../state/session"; + +/** Customer roster + creation. + * + * Shared by the admin and business roles: the service scopes the list off the + * token, so a business sees exactly its own customers without this screen + * filtering anything. `canCreateBusiness` is the one genuine difference — + * only an admin can mint a partner business. */ +export default function Customers({ canCreateBusiness }: { canCreateBusiness: boolean }) { + const { token, role } = useAuthed(); + const qc = useQueryClient(); + const customers = useQuery({ + queryKey: ["customers"], + queryFn: () => api.listCustomers(token), + }); + + const [name, setName] = useState(""); + const [ref, setRef] = useState(""); + const [type, setType] = useState<"individual" | "business">("individual"); + const [isSubClient, setIsSubClient] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [created, setCreated] = useState(null); + const [inviteFor, setInviteFor] = useState<{ id: string; invite: api.Invite } | null>(null); + + const create = async () => { + setBusy(true); + setError(null); + setCreated(null); + try { + setCreated( + await api.createCustomer(token, { + name, + customer_type: isSubClient ? "business" : type, + external_ref: ref || undefined, + is_sub_client: isSubClient || undefined, + with_invite: true, + }), + ); + setName(""); + setRef(""); + await qc.invalidateQueries({ queryKey: ["customers"] }); + } catch (e) { + setError(e); + } finally { + setBusy(false); + } + }; + + const approve = async (id: string) => { + setError(null); + try { + await api.simulateOnboarding(token, id); + await qc.invalidateQueries({ queryKey: ["customers"] }); + } catch (e) { + setError(e); + } + }; + + const invite = async (id: string) => { + setError(null); + try { + setInviteFor({ id, invite: await api.createInvite(token, id) }); + } catch (e) { + setError(e); + } + }; + + return ( + <> +

Customers

+ + + +
+ + + {!isSubClient && ( + + )} +
+ + {canCreateBusiness && ( + + )} + +
+ +
+
+ + {created && ( + +
+ Created {created.customer.dakota_customer_id}. +
+ + {created.invite && ( + + )} +

+ The Dakota link collects the verification data. The console link lets them sign in + here afterwards to run ramps. +

+
+ )} + + {inviteFor && ( + + +

Expires {fmtTime(inviteFor.invite.expires_at)}. Single use.

+
+ )} + + + {customers.isLoading ? ( + Loading… + ) : customers.data?.length ? ( + + + + + + + + + + } + > + {customers.data.map((c: Customer) => ( + + + + + + + + + + ))} +
IdRefTypeKYBApplicationCreated
{shortId(c.dakota_customer_id)}{c.external_ref ?? "—"} + {c.customer_type} + {c.is_sub_client ? " (partner)" : ""} + + + + + {fmtTime(c.created_at)} +
+ + {role === "admin" && c.kyb_status !== "active" && ( + + )} +
+
+ ) : ( + 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

+ + + +
{ + e.preventDefault(); + void run(() => auth.loginWithPassword(username, password)); + }} + > + + + +
+
+ + +
+ + +
+
+ +

+ 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. +

+
+
+ ); +} diff --git a/dakota-dashboard/src/screens/Ops.tsx b/dakota-dashboard/src/screens/Ops.tsx new file mode 100644 index 00000000..93a1333a --- /dev/null +++ b/dakota-dashboard/src/screens/Ops.tsx @@ -0,0 +1,84 @@ +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; + +import * as api from "../api/dakota"; +import { ErrorBox, Panel } from "../components/ui"; +import { useAuthed } from "../state/session"; + +/** Operational plumbing an admin occasionally needs to touch. */ +export default function Ops() { + const { token } = useAuthed(); + const targets = useQuery({ queryKey: ["webhooks"], queryFn: () => api.listWebhooks(token) }); + + const [error, setError] = useState(null); + const [note, setNote] = useState(null); + const [busy, setBusy] = useState(false); + + const run = async (fn: () => Promise) => { + setBusy(true); + setError(null); + setNote(null); + try { + setNote(await fn()); + await targets.refetch(); + } catch (e) { + setError(e); + } finally { + setBusy(false); + } + }; + + const registered = targets.data?.data ?? []; + + return ( + <> +

Ops

+ + {note &&
{note}
} + + +

+ {registered.length + ? `${registered.length} target(s) registered.` + : "No targets registered — the activity feed will stay empty."} +

+ +
+ + + +

+ Use this after registering the webhook late, or after downtime longer than Dakota's + 48-hour retry window. +

+
+ + ); +} diff --git a/dakota-dashboard/src/screens/Ramps.tsx b/dakota-dashboard/src/screens/Ramps.tsx new file mode 100644 index 00000000..307581bd --- /dev/null +++ b/dakota-dashboard/src/screens/Ramps.tsx @@ -0,0 +1,193 @@ +import { useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; + +import * as api from "../api/dakota"; +import RampForm from "../components/RampForm"; +import { Empty, ErrorBox, Panel, Table, fmtTime, shortId } from "../components/ui"; +import { SANDBOX_MAX_AMOUNT } from "../config"; +import { useAuthed } from "../state/session"; + +export default function Ramps() { + const { token, role } = useAuthed(); + const qc = useQueryClient(); + const catalog = useQuery({ queryKey: ["catalog"], queryFn: () => api.getCatalog(token) }); + const customers = useQuery({ queryKey: ["customers"], queryFn: () => api.listCustomers(token) }); + const accounts = useQuery({ queryKey: ["accounts"], queryFn: () => api.listAccounts(token) }); + + return ( + <> +

Ramps

+ + + {catalog.data && customers.data && ( + + )} + + + {accounts.data?.length ? ( + + + + + + + + + + } + > + {accounts.data.map((a) => ( + + + + + + + + + + ))} +
IdTypeCustomerSourceDestinationRailCreated
{shortId(a.dakota_account_id)}{a.account_type}{shortId(a.dakota_customer_id)} + {a.source_asset ?? "—"} + {a.source_network_id ? ` / ${a.source_network_id}` : ""} + + {a.destination_asset ?? "—"} + {a.destination_network_id ? ` / ${a.destination_network_id}` : ""} + {a.rail ?? "—"}{fmtTime(a.created_at)}
+ ) : ( + No ramp accounts yet. + )} +
+ + {role === "admin" && ( + { + void qc.invalidateQueries({ queryKey: ["feed"] }); + void qc.invalidateQueries({ queryKey: ["flows"] }); + }} + /> + )} + + ); +} + +/** Sandbox funding. + * + * In sandbox the banking rails are mocked, so an onramp is funded by + * simulating the inbound wire rather than actually sending one. Crypto legs + * settle for real on testnets — `crypto_inbound` simulates those too, which + * saves needing a funded testnet wallet just to exercise an offramp. */ +function SimulateDeposit({ + token, + accounts, + onDone, +}: { + token: string; + accounts: api.Account[]; + onDone: () => void; +}) { + const [accountId, setAccountId] = useState(""); + const [amount, setAmount] = useState("2.00"); + const [type, setType] = useState("ach_inbound"); + const [walletAddress, setWalletAddress] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [ok, setOk] = useState(false); + + const isCrypto = type === "crypto_inbound"; + const overCap = Number(amount) > SANDBOX_MAX_AMOUNT; + + const run = async () => { + setBusy(true); + setError(null); + setOk(false); + try { + await api.simulateInbound(token, { + type, + amount, + currency: "USD", + account_id: isCrypto ? undefined : accountId, + wallet_address: isCrypto ? walletAddress : undefined, + }); + setOk(true); + onDone(); + } catch (e) { + setError(e); + } finally { + setBusy(false); + } + }; + + return ( + + + {ok &&
Accepted. Webhooks will land shortly.
} + +
+ + {isCrypto ? ( + + ) : ( + + )} + +
+ + {overCap && ( +

+ Dakota's sandbox rejects anything above ${SANDBOX_MAX_AMOUNT.toFixed(2)}. +

+ )} + +
+ +
+
+ ); +} diff --git a/dakota-dashboard/src/screens/Settings.tsx b/dakota-dashboard/src/screens/Settings.tsx new file mode 100644 index 00000000..3288d028 --- /dev/null +++ b/dakota-dashboard/src/screens/Settings.tsx @@ -0,0 +1,179 @@ +import { useEffect, useState } from "react"; +import { ConnectButton, useCurrentAccount } from "@mysten/dapp-kit"; + +import * as auth from "../api/auth"; +import { Empty, ErrorBox, Panel, Table, fmtTime } from "../components/ui"; +import { useAuthed, useSession } from "../state/session"; +import { useWalletProof } from "./Login"; + +/** Manage the login methods attached to this account. + * + * Both directions of linking live here: a wallet account adding a password, + * and a password account adding a wallet. Either one then signs you in — they + * are two doors onto the same account, not two accounts. */ +export default function Settings() { + const { token } = useAuthed(); + const { setSession } = useSession(); + const account = useCurrentAccount(); + const proveWallet = useWalletProof(); + + const [me, setMe] = useState(null); + const [error, setError] = useState(null); + const [ok, setOk] = useState(null); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [busy, setBusy] = useState(false); + + const reload = () => { + auth.me(token).then(setMe, setError); + }; + useEffect(reload, [token]); + + const run = async (fn: () => Promise, message: string) => { + setBusy(true); + setError(null); + setOk(null); + try { + await fn(); + setOk(message); + reload(); + } catch (e) { + setError(e); + } finally { + setBusy(false); + } + }; + + const hasPassword = me?.identities.some((i) => i.kind === "password"); + const hasWallet = me?.identities.some((i) => i.kind === "sui_wallet"); + + return ( + <> +

Security

+ + {ok &&
{ok}
} + + +

+ {me ? ( + <> + {me.user_id} · role {me.role} + {me.scope ? ( + <> + {" "} + · scoped to {me.scope} + + ) : null} + + ) : ( + "Loading…" + )} +

+
+ + + {me?.identities.length ? ( + + + + + + + + } + > + {me.identities.map((i) => ( + + + + + + + + ))} +
MethodIdentifierAddedLast used
{i.kind === "password" ? "Password" : "Sui wallet"}{i.identifier}{fmtTime(i.created_at)}{fmtTime(i.last_used_at)} + +
+ ) : ( + Loading… + )} +
+ + {!hasPassword && ( + +
+ + +
+ +
+ )} + + {!hasWallet && ( + +
+ + +
+
+ )} + + + + + + ); +} diff --git a/dakota-dashboard/src/screens/Signup.tsx b/dakota-dashboard/src/screens/Signup.tsx new file mode 100644 index 00000000..11081ce8 --- /dev/null +++ b/dakota-dashboard/src/screens/Signup.tsx @@ -0,0 +1,138 @@ +import { useEffect, useState } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; +import { ConnectButton, useCurrentAccount } from "@mysten/dapp-kit"; + +import * as auth from "../api/auth"; +import { ErrorBox, Panel } from "../components/ui"; +import { homeFor, useSession } from "../state/session"; +import { useWalletProof } from "./Login"; + +/** Redeem an invite into an account. + * + * The invite carries the role and scope; nothing the visitor types here + * influences what they end up being able to see. */ +export default function Signup() { + const [params] = useSearchParams(); + const invite = params.get("invite") ?? ""; + const { setSession } = useSession(); + const navigate = useNavigate(); + const account = useCurrentAccount(); + const proveWallet = useWalletProof(); + + const [preview, setPreview] = useState> | null>(null); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [confirm, setConfirm] = useState(""); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + useEffect(() => { + if (!invite) return; + auth.previewInvite(invite).then(setPreview, setError); + }, [invite]); + + const run = async (fn: () => Promise) => { + setBusy(true); + setError(null); + try { + const s = await fn(); + setSession(s); + navigate(homeFor(s.role), { replace: true }); + } catch (e) { + setError(e); + } finally { + setBusy(false); + } + }; + + if (!invite) { + return ( +
+
+ +
+
+ ); + } + + const mismatch = confirm.length > 0 && confirm !== password; + const tooShort = password.length > 0 && password.length < 12; + + return ( +
+
+

Create your account

+ + + {preview && !preview.valid && ( + + )} + {preview?.valid && ( +

+ Joining as {preview.role} + {preview.label ? ` — ${preview.label}` : ""}. +

+ )} + + +
{ + e.preventDefault(); + void run(() => auth.register(invite, { username, password })); + }} + > + + + + {tooShort &&

Needs at least 12 characters.

} + {mismatch &&

Passwords do not match.

} + +
+
+ + +
+ + +
+
+
+
+ ); +} diff --git a/dakota-dashboard/src/screens/Treasury.tsx b/dakota-dashboard/src/screens/Treasury.tsx new file mode 100644 index 00000000..87c71012 --- /dev/null +++ b/dakota-dashboard/src/screens/Treasury.tsx @@ -0,0 +1,183 @@ +import { useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; + +import * as api from "../api/dakota"; +import { CopyField, Empty, ErrorBox, Panel, Table } from "../components/ui"; +import { SANDBOX_MAX_AMOUNT } from "../config"; +import { useAuthed } from "../state/session"; + +/** Our own non-custodial Dakota wallet. + * + * The private key lives server-side in Secrets Manager; this screen never + * touches key material. Sends are signed by dakota-service as endorsed + * requests — the browser only names the amount and destination. */ +export default function Treasury() { + const { token } = useAuthed(); + const qc = useQueryClient(); + const treasury = useQuery({ queryKey: ["treasury"], queryFn: () => api.getTreasury(token) }); + const catalog = useQuery({ queryKey: ["catalog"], queryFn: () => api.getCatalog(token) }); + + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + const wallets = treasury.data?.treasury ?? []; + + const setup = async () => { + setBusy(true); + setError(null); + try { + await api.setupTreasury(token); + await qc.invalidateQueries({ queryKey: ["treasury"] }); + } catch (e) { + setError(e); + } finally { + setBusy(false); + } + }; + + return ( + <> +

Treasury

+ + + {wallets.length === 0 && ( + + +

+ Requires dakota.wallet_p256_pem in the service's secrets. +

+
+ )} + + {wallets.map((entry: any) => ( + void qc.invalidateQueries({ queryKey: ["treasury"] })} + /> + ))} + + ); +} + +function WalletCard({ + token, + entry, + networks, + onSent, +}: { + token: string; + entry: any; + networks: api.Asset[]; + onSent: () => void; +}) { + const wallet = entry.wallet as { + dakota_wallet_id: string; + address: string | null; + family: string; + label: string | null; + }; + const balances = entry.balances as any; + + const [to, setTo] = useState(""); + const [amount, setAmount] = useState("1.00"); + const [assetKey, setAssetKey] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [ok, setOk] = useState(false); + + const selected = networks.find((a) => `${a.symbol}@${a.network_id}` === assetKey); + const overCap = Number(amount) > SANDBOX_MAX_AMOUNT; + + const send = async () => { + if (!selected) return; + setBusy(true); + setError(null); + setOk(false); + try { + await api.treasurySend(token, wallet.dakota_wallet_id, { + to, + amount, + asset_id: selected.symbol, + network_id: selected.network_id, + }); + setOk(true); + onSent(); + } catch (e) { + setError(e); + } finally { + setBusy(false); + } + }; + + return ( + + {wallet.address && } + + {balances?.balances?.length ? ( + + + + + + } + > + {balances.balances.map((b: any, i: number) => ( + + + + + + ))} +
AssetNetworkAmount
{b.asset ?? b.asset_id ?? "—"}{b.network_id ?? "—"}{b.amount ?? "—"}
+ ) : ( + + Empty wallet{balances?.total_amount_usd ? ` (${balances.total_amount_usd} USD)` : ""}. + + )} + +

Send

+ + {ok &&
Submitted.
} +
+ + + +
+ {overCap && ( +

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, AuthzError> { + match caller { + Caller::Admin => Ok(requested.map(|s| s.to_string())), + Caller::Business { sub_client_id } => { + if let Some(req) = requested { + if req != sub_client_id { + return Err(( + StatusCode::FORBIDDEN, + "a business may only create customers beneath itself".into(), + )); + } + } + Ok(Some(sub_client_id.clone())) + } + Caller::Individual { .. } => Err(( + StatusCode::FORBIDDEN, + "individuals cannot create customers".into(), + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn claims(role: &str, scope: Option<&str>) -> VerifiedClaims { + VerifiedClaims { + address: String::new(), + user_id: "u1".into(), + role: role.into(), + scope: scope.map(|s| s.into()), + exp: 0, + } + } + + #[test] + fn roles_map_to_callers() { + assert!(Caller::from_claims(&claims("admin", None)).unwrap().is_admin()); + assert!(matches!( + Caller::from_claims(&claims("business", Some("sub1"))).unwrap(), + Caller::Business { .. } + )); + assert!(matches!( + Caller::from_claims(&claims("individual", Some("cus1"))).unwrap(), + Caller::Individual { .. } + )); + } + + #[test] + fn a_scoped_role_without_a_scope_is_refused() { + // Failing closed matters: defaulting to "no filter" would hand a + // business the whole platform. + assert!(Caller::from_claims(&claims("business", None)).is_err()); + assert!(Caller::from_claims(&claims("individual", None)).is_err()); + } + + #[test] + fn unknown_role_is_refused() { + assert!(Caller::from_claims(&claims("superuser", None)).is_err()); + } + + #[test] + fn only_admin_lists_unfiltered() { + assert_eq!(Caller::Admin.sub_client_filter(), None); + assert_eq!( + Caller::Business { sub_client_id: "sub1".into() }.sub_client_filter(), + Some("sub1") + ); + } + + #[test] + fn business_cannot_file_a_customer_under_another_business() { + let biz = Caller::Business { sub_client_id: "sub1".into() }; + // Ignoring the requested value and using the token's is the safe move. + assert_eq!(creation_sub_client(&biz, None).unwrap().as_deref(), Some("sub1")); + assert_eq!(creation_sub_client(&biz, Some("sub1")).unwrap().as_deref(), Some("sub1")); + assert!(creation_sub_client(&biz, Some("sub2")).is_err()); + } + + #[test] + fn admin_may_place_a_customer_anywhere() { + assert_eq!(creation_sub_client(&Caller::Admin, None).unwrap(), None); + assert_eq!( + creation_sub_client(&Caller::Admin, Some("sub9")).unwrap().as_deref(), + Some("sub9") + ); + } + + #[test] + fn individuals_cannot_create_customers() { + let ind = Caller::Individual { customer_id: "cus1".into() }; + assert!(creation_sub_client(&ind, None).is_err()); + } + + #[test] + fn require_admin_gates_control_plane() { + assert!(Caller::Admin.require_admin().is_ok()); + assert!(Caller::Business { sub_client_id: "s".into() }.require_admin().is_err()); + assert!(Caller::Individual { customer_id: "c".into() }.require_admin().is_err()); + } +} diff --git a/rust-backend/services/dakota-service/src/config.rs b/rust-backend/services/dakota-service/src/config.rs new file mode 100644 index 00000000..82053d6e --- /dev/null +++ b/rust-backend/services/dakota-service/src/config.rs @@ -0,0 +1,90 @@ +//! Service config, loaded via `runtime_config::config_load` so `${DB_HOST}` / +//! `${DB_PASSWORD}` expand from the environment at boot. + +use std::net::SocketAddr; +use std::path::Path; + +use anyhow::Result; +use serde::Deserialize; + +fn default_db_pool_size() -> u32 { + 4 +} +fn default_origins() -> Vec { + vec!["*".to_string()] +} +fn default_invite_ttl() -> i64 { + 7 * 86_400 +} +/// Sandbox refuses anything above $2.00. Enforcing it here turns an opaque +/// Dakota rejection into a clear message before we spend a round-trip. +fn default_max_amount_minor() -> i64 { + 200 +} + +#[derive(Debug, Clone, Deserialize)] +pub struct Config { + pub environment: String, + pub bind_addr: SocketAddr, + + pub database_url: String, + #[serde(default = "default_db_pool_size")] + pub db_pool_size: u32, + + #[serde(default = "default_origins")] + pub allowed_origins: Vec, + + pub dakota: DakotaConfig, + pub auth: AuthConfig, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct DakotaConfig { + /// `https://api.platform.sandbox.dakota.xyz` for sandbox. + pub base_url: String, + + /// Ed25519 public key that signs webhook deliveries, hex. Environment + /// specific — the sandbox and production keys differ, and using the wrong + /// one rejects every delivery. + pub webhook_public_key: String, + + /// Publicly reachable URL Dakota should deliver to. Registered by the admin + /// `POST /admin/webhooks/register` route rather than at boot, so a restart + /// does not churn targets. + #[serde(default)] + pub webhook_url: Option, + + /// Hard ceiling on any single transfer, in minor units. Exists because the + /// sandbox caps at $2.00; raising it will not lift Dakota's own limit. + #[serde(default = "default_max_amount_minor")] + pub max_amount_minor: i64, + + /// Networks we will send to Dakota. Sandbox rejects mainnet ids outright, + /// so listing only testnets here turns a confusing downstream 400 into a + /// local one — and stops a fat-fingered mainnet id ever leaving the box. + #[serde(default)] + pub allowed_networks: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct AuthConfig { + /// auth-service's INTERNAL base url (`http://auth-service:9008`). Used both + /// to verify tokens and to mint invites. + pub internal_url: String, + /// Lifetime of invites this service mints, seconds. + #[serde(default = "default_invite_ttl")] + pub invite_ttl_secs: i64, +} + +impl Config { + pub fn load>(path: P) -> Result { + runtime_config::config_load::load_toml(path) + } + + /// Whether `network` may be sent to Dakota. An empty allow-list permits + /// everything, which is what local dev wants. + pub fn network_allowed(&self, network: &str) -> bool { + let list = &self.dakota.allowed_networks; + list.is_empty() || list.iter().any(|n| n == network) + } +} diff --git a/rust-backend/services/dakota-service/src/dakota/client.rs b/rust-backend/services/dakota-service/src/dakota/client.rs new file mode 100644 index 00000000..d38044a0 --- /dev/null +++ b/rust-backend/services/dakota-service/src/dakota/client.rs @@ -0,0 +1,247 @@ +//! Thin typed client for the Dakota platform API. +//! +//! Two conventions are enforced here rather than at every call site, because +//! both are easy to forget and fail in confusing ways: +//! +//! - `x-api-key` on every request. +//! - `x-idempotency-key` (a fresh UUID) on **POST only**. Dakota rejects a POST +//! without one as a 400, and rejects one *with* it on GET/PUT/PATCH/DELETE. +//! +//! Every call goes through `observability::client::instrumented`, so Dakota +//! latency and failures show up in Tempo next to the request that caused them. + +use anyhow::Result; +use axum::http::StatusCode; +use reqwest::header::{HeaderMap, HeaderValue, CONTENT_TYPE}; +use reqwest::Method; +use serde::de::DeserializeOwned; +use serde::Serialize; +use tracing::warn; +use uuid::Uuid; + +use super::error::{DakotaError, ProblemDetails}; + +#[derive(Clone)] +pub struct DakotaClient { + base_url: String, + api_key: String, + http: reqwest::Client, +} + +impl DakotaClient { + pub fn new(base_url: impl Into, api_key: impl Into) -> Self { + Self { + base_url: base_url.into().trim_end_matches('/').to_string(), + api_key: api_key.into(), + http: reqwest::Client::new(), + } + } + + pub fn base_url(&self) -> &str { + &self.base_url + } + + /// `op` is the low-cardinality route *template* (`"GET /customers/{id}"`), + /// not the concrete path — it becomes a metric label, and interpolating + /// KSUIDs into it would give every customer their own time series. + pub async fn get( + &self, + op: &'static str, + path: &str, + ) -> Result { + self.send(Method::GET, op, path, None::<&()>).await + } + + pub async fn post( + &self, + op: &'static str, + path: &str, + body: &B, + ) -> Result { + self.send(Method::POST, op, path, Some(body)).await + } + + pub async fn put( + &self, + op: &'static str, + path: &str, + body: &B, + ) -> Result { + self.send(Method::PUT, op, path, Some(body)).await + } + + pub async fn delete( + &self, + op: &'static str, + path: &str, + ) -> Result { + self.send(Method::DELETE, op, path, None::<&()>).await + } + + async fn send( + &self, + method: Method, + op: &'static str, + path: &str, + body: Option<&B>, + ) -> Result { + let url = format!("{}{}", self.base_url, path); + let is_post = method == Method::POST; + + let resp = observability::client::instrumented("dakota", op, |trace_headers| { + let mut req = self + .http + .request(method.clone(), &url) + .headers(trace_headers) + .headers(self.auth_headers(is_post)); + if let Some(b) = body { + req = req.json(b); + } + req.send() + }) + .await?; + + let status = StatusCode::from_u16(resp.status().as_u16()) + .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); + let text = resp.text().await?; + + if !status.is_success() { + return Err(match serde_json::from_str::(&text) { + Ok(problem) => { + warn!( + %status, + op, + detail = problem.detail.as_deref().unwrap_or(""), + request_id = problem.request_id.as_deref().unwrap_or(""), + "dakota rejected the request" + ); + DakotaError::Api { status, problem: Box::new(problem) } + } + Err(_) => DakotaError::Malformed { status, snippet: snippet(&text) }, + }); + } + + // 204 and friends: no body, but the caller may still want `()`. + if text.trim().is_empty() { + return serde_json::from_str("null") + .map_err(|_| DakotaError::Malformed { status, snippet: "empty body".into() }); + } + + serde_json::from_str(&text).map_err(|e| { + warn!(op, error = %e, "dakota response did not match the expected shape"); + DakotaError::Malformed { status, snippet: snippet(&text) } + }) + } + + fn auth_headers(&self, with_idempotency: bool) -> HeaderMap { + let mut h = HeaderMap::new(); + h.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); + if let Ok(v) = HeaderValue::from_str(&self.api_key) { + h.insert("x-api-key", v); + } + if with_idempotency { + // A fresh key per attempt. Dakota only requires the header to be + // present and a valid UUID; we are not retrying at this layer, so + // reusing one across calls would collapse distinct requests. + if let Ok(v) = HeaderValue::from_str(&Uuid::new_v4().to_string()) { + h.insert("x-idempotency-key", v); + } + } + h + } +} + +/// Bound an unparseable body so a gateway HTML page cannot flood the logs. +fn snippet(text: &str) -> String { + const MAX: usize = 300; + let trimmed = text.trim(); + if trimmed.chars().count() <= MAX { + return trimmed.to_string(); + } + let cut: String = trimmed.chars().take(MAX).collect(); + format!("{cut}…") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn base_url_trailing_slash_is_normalized() { + let c = DakotaClient::new("https://api.example.com/", "k"); + assert_eq!(c.base_url(), "https://api.example.com"); + } + + #[test] + fn idempotency_key_only_on_post() { + let c = DakotaClient::new("https://api.example.com", "k"); + // Dakota 400s a POST without it... + assert!(c.auth_headers(true).contains_key("x-idempotency-key")); + // ...and rejects it on every other method. + assert!(!c.auth_headers(false).contains_key("x-idempotency-key")); + } + + #[test] + fn idempotency_keys_are_unique_per_call() { + let c = DakotaClient::new("https://api.example.com", "k"); + let a = c.auth_headers(true).get("x-idempotency-key").unwrap().clone(); + let b = c.auth_headers(true).get("x-idempotency-key").unwrap().clone(); + assert_ne!(a, b, "a shared key would collapse distinct requests"); + } + + #[test] + fn api_key_header_is_set() { + let c = DakotaClient::new("https://api.example.com", "secret-key"); + assert_eq!( + c.auth_headers(false).get("x-api-key").unwrap().to_str().unwrap(), + "secret-key" + ); + } + + #[test] + fn snippet_is_bounded() { + let long = "x".repeat(5000); + let s = snippet(&long); + assert!(s.chars().count() <= 301, "got {} chars", s.chars().count()); + } + + #[test] + fn problem_details_parse_from_a_real_dakota_error() { + // Captured verbatim from the sandbox. + let raw = r#"{"detail":"amount 5 exceeds sandbox cap of 2; reduce the amount and retry", + "instance":"/sandbox/simulate/inbound","request_id":"3HNCPFPG5Rt3llmaw7dcacxV8UT", + "status":400,"title":"Invalid Request", + "type":"https://docs.dakota.xyz/api-reference/errors#invalid-request"}"#; + let p: ProblemDetails = serde_json::from_str(raw).unwrap(); + assert_eq!(p.status, Some(400)); + assert!(p.detail.unwrap().contains("sandbox cap")); + assert_eq!(p.request_id.as_deref(), Some("3HNCPFPG5Rt3llmaw7dcacxV8UT")); + } + + #[test] + fn validation_errors_carry_field_detail() { + let raw = r#"{"title":"Validation Error","status":400, + "detail":"Request body validation failed - missing required field 'type'", + "errors":[{"field":"type","message":"missing required field 'type'", + "code":"missing_required_field"}]}"#; + let p: ProblemDetails = serde_json::from_str(raw).unwrap(); + assert_eq!(p.errors.len(), 1); + assert_eq!(p.errors[0].field.as_deref(), Some("type")); + } + + #[test] + fn client_errors_relay_but_server_errors_become_bad_gateway() { + let problem = Box::new(ProblemDetails { + r#type: None, title: None, status: Some(400), detail: None, + instance: None, request_id: None, errors: vec![], + }); + let e = DakotaError::Api { status: StatusCode::BAD_REQUEST, problem: problem.clone() }; + assert_eq!(e.client_status(), StatusCode::BAD_REQUEST); + + let e = DakotaError::Api { status: StatusCode::INTERNAL_SERVER_ERROR, problem }; + assert_eq!(e.client_status(), StatusCode::BAD_GATEWAY); + + let e = DakotaError::Malformed { status: StatusCode::OK, snippet: "".into() }; + assert_eq!(e.client_status(), StatusCode::BAD_GATEWAY); + } +} diff --git a/rust-backend/services/dakota-service/src/dakota/error.rs b/rust-backend/services/dakota-service/src/dakota/error.rs new file mode 100644 index 00000000..c295908b --- /dev/null +++ b/rust-backend/services/dakota-service/src/dakota/error.rs @@ -0,0 +1,93 @@ +//! Dakota returns RFC 9457 Problem Details on every failure. Preserving the +//! status and `detail` verbatim matters: Dakota's messages are specific and +//! actionable ("capabilities are required", "Customer is not KYB-approved by +//! Dakota", "amount 5 exceeds sandbox cap of 2"), and flattening them into a +//! generic 502 would throw away the one thing that tells an operator what to fix. + +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use serde::{Deserialize, Serialize}; + +/// RFC 9457 Problem Details, as Dakota emits it. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ProblemDetails { + #[serde(default)] + pub r#type: Option, + #[serde(default)] + pub title: Option, + #[serde(default)] + pub status: Option, + #[serde(default)] + pub detail: Option, + #[serde(default)] + pub instance: Option, + /// Dakota's correlation id. Always worth surfacing — it is what their + /// support asks for first. + #[serde(default)] + pub request_id: Option, + #[serde(default)] + pub errors: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct FieldError { + #[serde(default)] + pub field: Option, + #[serde(default)] + pub message: Option, + #[serde(default)] + pub code: Option, +} + +#[derive(Debug, thiserror::Error)] +pub enum DakotaError { + /// Dakota answered, and said no. + #[error("dakota {status}: {}", .problem.detail.as_deref().unwrap_or("(no detail)"))] + Api { + status: StatusCode, + problem: Box, + }, + /// Dakota answered with something we could not parse — a gateway error + /// page, a truncated body, a schema change. + #[error("dakota returned an unreadable {status} body: {snippet}")] + Malformed { status: StatusCode, snippet: String }, + /// We never got an answer. + #[error("reaching dakota: {0}")] + Transport(#[from] reqwest::Error), +} + +impl DakotaError { + /// Status to hand back to our own caller. + /// + /// A 4xx from Dakota is relayed unchanged, because it is genuinely the + /// caller's problem to fix. Everything else becomes 502: it is our + /// dependency that failed, not their request. + pub fn client_status(&self) -> StatusCode { + match self { + DakotaError::Api { status, .. } if status.is_client_error() => *status, + _ => StatusCode::BAD_GATEWAY, + } + } + + pub fn request_id(&self) -> Option<&str> { + match self { + DakotaError::Api { problem, .. } => problem.request_id.as_deref(), + _ => None, + } + } +} + +impl IntoResponse for DakotaError { + fn into_response(self) -> Response { + let status = self.client_status(); + let body = match &self { + DakotaError::Api { problem, .. } => serde_json::json!({ + "error": problem.detail.clone().or_else(|| problem.title.clone()), + "dakota_request_id": problem.request_id, + "fields": problem.errors, + }), + other => serde_json::json!({ "error": other.to_string() }), + }; + (status, axum::Json(body)).into_response() + } +} diff --git a/rust-backend/services/dakota-service/src/dakota/mod.rs b/rust-backend/services/dakota-service/src/dakota/mod.rs new file mode 100644 index 00000000..4ccd1aa2 --- /dev/null +++ b/rust-backend/services/dakota-service/src/dakota/mod.rs @@ -0,0 +1,8 @@ +//! Dakota platform API: client, error mapping and wire types. + +pub mod client; +pub mod error; +pub mod types; + +pub use client::DakotaClient; +pub use error::{DakotaError, ProblemDetails}; diff --git a/rust-backend/services/dakota-service/src/dakota/types.rs b/rust-backend/services/dakota-service/src/dakota/types.rs new file mode 100644 index 00000000..d58e06fd --- /dev/null +++ b/rust-backend/services/dakota-service/src/dakota/types.rs @@ -0,0 +1,329 @@ +//! Wire types for the Dakota platform API. +//! +//! Shapes here were confirmed against the live sandbox (see +//! `docs/dakota-sandbox-notes.md`), not just read off the docs — several +//! documented shapes are wrong. Where the two disagree the live behaviour wins, +//! and the difference is called out in a comment. +//! +//! Response structs are deliberately partial: we deserialize only what we act +//! on. Dakota bodies carry PII (`email`, `account_holder_name`, +//! `sender_account_number`) and anything named here is a field we could +//! accidentally persist, so the smaller this file is, the safer it is. Handlers +//! that need to relay a full body to the browser pass `serde_json::Value` +//! through without ever binding it to a struct. + +use serde::{Deserialize, Serialize}; + +// ----------------------------------------------------------------- customers + +#[derive(Debug, Clone, Serialize)] +pub struct CreateCustomerReq { + pub name: String, + pub customer_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub external_id: Option, + /// Mutually exclusive with `sub_client_id`, and immutable after creation. + #[serde(skip_serializing_if = "Option::is_none")] + pub is_sub_client: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sub_client_id: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct CreateCustomerResp { + pub id: String, + pub application_id: String, + /// Hosted onboarding form with an embedded token. This is the whole + /// no-PII strategy: we hand the customer here and never see what they type. + pub application_url: String, + /// NANOseconds since epoch, unlike every other timestamp in this API. + #[serde(default)] + pub application_expires_at: Option, +} + +/// A customer as Dakota returns it. `name` is present in the real payload and +/// deliberately absent here — it is PII, it is never stored, and handlers that +/// display it relay the raw body instead. +#[derive(Debug, Clone, Deserialize)] +pub struct CustomerStatus { + pub id: String, + pub customer_type: String, + #[serde(default)] + pub is_sub_client: bool, + #[serde(default)] + pub sub_client_id: Option, + #[serde(default)] + pub external_id: Option, + #[serde(default)] + pub kyb_status: Option, + #[serde(default)] + pub kyc_status: Option, + #[serde(default)] + pub application_id: Option, + #[serde(default)] + pub application_status: Option, +} + +impl CustomerStatus { + /// Whether Dakota will let this customer open a ramp account. + /// + /// `kyb_status == "active"` is the real gate, for individuals as much as + /// businesses — `POST /accounts` fails with "Customer is not KYB-approved + /// by Dakota" otherwise, and an individual sits at `kyc_status: "pending"` + /// even once approved. Checking `kyc_status` here would reject every + /// perfectly good individual. + pub fn can_transact(&self) -> bool { + self.kyb_status.as_deref() == Some("active") + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct Paginated { + #[serde(default = "Vec::new")] + pub data: Vec, + #[serde(default)] + pub meta: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct PageMeta { + #[serde(default)] + pub has_more_after: bool, + #[serde(default)] + pub has_more_before: bool, + #[serde(default)] + pub total_count: Option, +} + +// ---------------------------------------------------- recipients + destinations + +#[derive(Debug, Clone, Serialize)] +pub struct CreateRecipientReq { + pub name: String, + /// Optional for crypto-only recipients; Dakota requires it before any + /// fiat destination can be attached, so an offramp needs it up front. + #[serde(skip_serializing_if = "Option::is_none")] + pub address: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CreatedId { + pub id: String, +} + +// ------------------------------------------------------------------ accounts + +#[derive(Debug, Clone, Serialize)] +pub struct CreateAccountReq { + pub account_type: String, + /// Required for onramps. Undocumented as required — Dakota 400s with + /// "capabilities are required" when it is missing. + #[serde(skip_serializing_if = "Option::is_none")] + pub capabilities: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub crypto_destination_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub fiat_destination_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub destination_network_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub source_network_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub source_asset: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub destination_asset: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub developer_fee_bps: Option, +} + +/// Only the fields we index on. The full response also carries `bank_account` +/// (routing + account number + holder name) — pure PII, relayed to the browser +/// and never bound here. +#[derive(Debug, Clone, Deserialize)] +pub struct AccountSummary { + pub id: String, + pub account_type: String, + #[serde(default)] + pub source_asset: Option, + #[serde(default)] + pub destination_asset: Option, + #[serde(default)] + pub source_network_id: Option, + #[serde(default)] + pub rail: Option, + /// Deposit address for offramps and swaps. + #[serde(default)] + pub source_crypto_address: Option, +} + +// -------------------------------------------------------------- transactions + +/// Fee and rate breakdown Dakota attaches to each transaction. This is the +/// only place real rates are observable — there is no pricing endpoint +/// available to our client tier. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Receipt { + #[serde(default)] + pub input: Option, + #[serde(default)] + pub output: Option, + #[serde(default)] + pub exchange_rate: Option, + #[serde(default)] + pub dakota_fee: Option, + #[serde(default)] + pub client_fee: Option, + #[serde(default)] + pub external_fee: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Amount { + #[serde(default)] + pub amount: Option, + #[serde(default)] + pub asset: Option, +} + +/// An auto-account transaction. `sender_details` exists on the wire and is +/// omitted here on purpose — it holds the sender's name and bank account. +#[derive(Debug, Clone, Deserialize)] +pub struct AutoTransaction { + pub id: String, + #[serde(default)] + pub auto_account_id: Option, + #[serde(default)] + pub destination_id: Option, + #[serde(default)] + pub r#type: Option, + #[serde(default)] + pub status: Option, + #[serde(default)] + pub receipt: Option, + #[serde(default)] + pub failure_reason: Option, + #[serde(default)] + pub created_at: Option, + #[serde(default)] + pub updated_at: Option, +} + +// ------------------------------------------------------------------- sandbox + +#[derive(Debug, Clone, Serialize)] +pub struct SimulateInboundReq { + pub simulation_id: String, + /// `ach_inbound` | `fedwire_inbound` | `fednow_inbound` | `crypto_inbound` + /// and the outbound/reversal variants. + pub r#type: String, + pub amount: String, + pub currency: String, + /// Required for the fiat inbound types. + #[serde(skip_serializing_if = "Option::is_none")] + pub account_id: Option, + /// Required for `crypto_inbound`. + #[serde(skip_serializing_if = "Option::is_none")] + pub wallet_address: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub scenario: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct SimulateOnboardingReq { + /// `kyb_approve` drives the state machine for individuals as well as + /// businesses; `kyc_approve` on a fresh individual is a no-op. + pub r#type: String, + /// The **application** id, not the customer id — the docs' example is wrong. + pub applicant_id: String, + pub simulation_id: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct SimulationResp { + #[serde(default)] + pub simulation_id: Option, + #[serde(default)] + pub previous_state: Option, + #[serde(default)] + pub new_state: Option, + #[serde(default)] + pub state: Option, +} + +// ------------------------------------------------------------------ webhooks + +#[derive(Debug, Clone, Serialize)] +pub struct CreateWebhookTargetReq { + pub url: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub event_types: Option>, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn kyb_active_is_the_transact_gate_even_for_individuals() { + // Captured from the sandbox after `kyb_approve` on an individual: the + // customer transacts fine while kyc_status is still "pending". + let raw = r#"{"id":"c1","customer_type":"individual","kyb_status":"active", + "kyc_status":"pending","application_status":"approved"}"#; + let c: CustomerStatus = serde_json::from_str(raw).unwrap(); + assert!(c.can_transact(), "gating on kyc_status would reject this customer"); + } + + #[test] + fn pending_customer_cannot_transact() { + let raw = r#"{"id":"c1","customer_type":"individual","kyb_status":"pending"}"#; + let c: CustomerStatus = serde_json::from_str(raw).unwrap(); + assert!(!c.can_transact()); + } + + #[test] + fn customer_parses_without_optional_fields() { + let c: CustomerStatus = + serde_json::from_str(r#"{"id":"c1","customer_type":"business"}"#).unwrap(); + assert!(!c.is_sub_client); + assert!(!c.can_transact()); + } + + #[test] + fn create_customer_omits_unset_discriminators() { + // `is_sub_client` and `sub_client_id` are mutually exclusive; sending + // either as null would be a 400. + let body = serde_json::to_value(CreateCustomerReq { + name: "Acme".into(), + customer_type: "business".into(), + external_id: None, + is_sub_client: Some(true), + sub_client_id: None, + }) + .unwrap(); + assert_eq!(body.get("is_sub_client").and_then(|v| v.as_bool()), Some(true)); + assert!(body.get("sub_client_id").is_none()); + assert!(body.get("external_id").is_none()); + } + + #[test] + fn paginated_tolerates_a_missing_data_array() { + let p: Paginated = serde_json::from_str(r#"{"meta":{}}"#).unwrap(); + assert!(p.data.is_empty()); + } + + #[test] + fn auto_transaction_parses_the_real_sandbox_payload() { + // Trimmed from a live onramp; sender_details intentionally not bound. + let raw = r#"{"auto_account_id":"3HNCN914HGh2Sr95XpcJBgMPLAT","status":"processing", + "id":"3HNCPYPeEZCpScXWQbvFJwUeIxB","type":"onramp","failure_reason":"", + "receipt":{"exchange_rate":"1","input":{"amount":"2","asset":"USD"}, + "output":{"amount":"2","asset":"USDC"}, + "dakota_fee":{"amount":"0","asset":"USD"}}, + "created_at":1785699116,"updated_at":1785699116}"#; + let t: AutoTransaction = serde_json::from_str(raw).unwrap(); + assert_eq!(t.status.as_deref(), Some("processing")); + let r = t.receipt.unwrap(); + assert_eq!(r.exchange_rate.as_deref(), Some("1")); + assert_eq!(r.output.unwrap().asset.as_deref(), Some("USDC")); + } +} diff --git a/rust-backend/services/dakota-service/src/db/migrations/000001_init/down.sql b/rust-backend/services/dakota-service/src/db/migrations/000001_init/down.sql new file mode 100644 index 00000000..166501c2 --- /dev/null +++ b/rust-backend/services/dakota-service/src/db/migrations/000001_init/down.sql @@ -0,0 +1,7 @@ +DROP TABLE IF EXISTS webhook_errors; +DROP TABLE IF EXISTS wallets; +DROP TABLE IF EXISTS ledger_events; +DROP TABLE IF EXISTS accounts; +DROP TABLE IF EXISTS customers; +DROP TABLE IF EXISTS fee_schedule; +DROP TABLE IF EXISTS assets; diff --git a/rust-backend/services/dakota-service/src/db/migrations/000001_init/up.sql b/rust-backend/services/dakota-service/src/db/migrations/000001_init/up.sql new file mode 100644 index 00000000..64c580b2 --- /dev/null +++ b/rust-backend/services/dakota-service/src/db/migrations/000001_init/up.sql @@ -0,0 +1,134 @@ +-- dakota-service read model. +-- +-- POLICY: this schema stores NO personally identifying information. Every +-- Dakota response we touch is 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.sender_account_name` +-- and `sender_account_number`. None of that lands here. +-- +-- What we keep is the skeleton needed to aggregate and authorize: Dakota +-- KSUIDs, enums, amounts, assets and timestamps. Anything a human would +-- recognize as a person is fetched from Dakota per-request and relayed +-- straight to the browser. Adding a `name` column here would quietly break +-- that promise, so don't. + +-- Admin-curated catalog of what we support. Dakota has no assets endpoint — +-- `/capabilities/networks` returns bare network ids and nothing about assets — +-- so this table IS the source of truth for every dropdown in the dashboard. +CREATE TABLE assets ( + id SERIAL PRIMARY KEY, + symbol TEXT NOT NULL, + network_id TEXT NOT NULL, + onramp_enabled BOOLEAN NOT NULL DEFAULT false, + offramp_enabled BOOLEAN NOT NULL DEFAULT false, + swap_enabled BOOLEAN NOT NULL DEFAULT false, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + UNIQUE (symbol, network_id) +); + +-- Expected fee schedule. `GET /self-serve/credits/pricing` 403s for our client +-- tier ("Credit management is only available for self-serve customers"), so +-- `source = 'manual'` is the only row we can actually produce today. The +-- 'dakota' source exists so a future tier change needs no migration. +CREATE TABLE fee_schedule ( + id SERIAL PRIMARY KEY, + source TEXT NOT NULL DEFAULT 'manual', + transfer_fee_bps INTEGER, + ach_fee_cents INTEGER, + wire_fee_cents INTEGER, + sepa_fee_cents INTEGER, + swift_fee_cents INTEGER, + kyc_fee_cents INTEGER, + kyb_fee_cents INTEGER, + effective_from TIMESTAMPTZ NOT NULL DEFAULT now(), + fetched_at TIMESTAMPTZ NOT NULL DEFAULT now(), + note TEXT +); + +-- Customer skeleton. No name, no email — see the policy note above. +CREATE TABLE customers ( + dakota_customer_id TEXT PRIMARY KEY, + customer_type TEXT NOT NULL, + is_sub_client BOOLEAN NOT NULL DEFAULT false, + -- The partner business this customer belongs to, if any. This is what + -- makes the three-tier hierarchy queryable without asking Dakota. + sub_client_id TEXT, + external_ref TEXT, + application_id TEXT, + kyb_status TEXT, + kyc_status TEXT, + application_status TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX customers_sub_client_idx ON customers (sub_client_id) WHERE sub_client_id IS NOT NULL; +CREATE INDEX customers_is_sub_client_idx ON customers (is_sub_client) WHERE is_sub_client; + +CREATE TABLE accounts ( + dakota_account_id TEXT PRIMARY KEY, + dakota_customer_id TEXT NOT NULL REFERENCES customers (dakota_customer_id) ON DELETE CASCADE, + account_type TEXT NOT NULL, + source_asset TEXT, + source_network_id TEXT, + destination_asset TEXT, + destination_network_id TEXT, + rail TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX accounts_customer_idx ON accounts (dakota_customer_id); + +-- The activity ledger behind every flow-tracking view. +-- +-- Keyed on Dakota's `X-Dakota-Event-ID` so redeliveries are idempotent — +-- Dakota retries ~10 times over 48h and does NOT guarantee ordering, so this +-- table is a set of observations, not a sequence. Treat the resource's current +-- status as authoritative rather than the newest row. +-- +-- Note what is absent: no raw payload column. Dakota's event bodies carry +-- sender names and bank account numbers, so we extract the handful of +-- non-identifying fields below and drop the rest on the floor. +CREATE TABLE ledger_events ( + event_id TEXT PRIMARY KEY, + event_type TEXT NOT NULL, + resource_type TEXT, + resource_id TEXT, + dakota_customer_id TEXT, + direction TEXT, + amount_minor BIGINT, + asset TEXT, + exchange_rate TEXT, + fee_minor BIGINT, + status TEXT, + occurred_at TIMESTAMPTZ, + received_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX ledger_events_customer_idx ON ledger_events (dakota_customer_id, occurred_at DESC); +CREATE INDEX ledger_events_resource_idx ON ledger_events (resource_id); +CREATE INDEX ledger_events_type_idx ON ledger_events (event_type); + +CREATE TABLE wallets ( + dakota_wallet_id TEXT PRIMARY KEY, + address TEXT, + family TEXT NOT NULL, + signer_group_id TEXT, + policy_id TEXT, + label TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Webhooks we could not verify or parse. Stores a SHA-256 of the body, never +-- the body: a delivery that failed to parse is exactly as likely to contain +-- PII as one that succeeded. +CREATE TABLE webhook_errors ( + id SERIAL PRIMARY KEY, + event_id TEXT, + reason TEXT NOT NULL, + body_sha256 TEXT NOT NULL, + received_at TIMESTAMPTZ NOT NULL DEFAULT now() +); diff --git a/rust-backend/services/dakota-service/src/db/mod.rs b/rust-backend/services/dakota-service/src/db/mod.rs new file mode 100644 index 00000000..d6ceb024 --- /dev/null +++ b/rust-backend/services/dakota-service/src/db/mod.rs @@ -0,0 +1,30 @@ +//! Postgres persistence. Same diesel + r2d2 + embedded-migration shape as the +//! indexer / cctp-relay `db` modules. + +pub mod models; +pub mod repo; +pub mod schema; + +use anyhow::{Context, Result}; +use diesel::pg::PgConnection; +use diesel::r2d2::{ConnectionManager, Pool}; +use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness}; + +pub type DbPool = Pool>; + +pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("src/db/migrations"); + +pub fn establish_pool(database_url: &str, max_size: u32) -> Result { + let manager = ConnectionManager::::new(database_url); + Pool::builder() + .max_size(max_size) + .build(manager) + .context("building r2d2 pool for the dakota-service DB") +} + +pub fn run_migrations(pool: &DbPool) -> Result<()> { + let mut conn = pool.get().context("checking out connection for migrations")?; + conn.run_pending_migrations(MIGRATIONS) + .map_err(|e| anyhow::anyhow!("running migrations: {e}"))?; + Ok(()) +} diff --git a/rust-backend/services/dakota-service/src/db/models.rs b/rust-backend/services/dakota-service/src/db/models.rs new file mode 100644 index 00000000..76e49da3 --- /dev/null +++ b/rust-backend/services/dakota-service/src/db/models.rs @@ -0,0 +1,222 @@ +//! Row structs for the read model. Nothing here holds PII — see the policy +//! note at the top of `migrations/000001_init/up.sql`. + +use chrono::{DateTime, Utc}; +use diesel::prelude::*; +use serde::{Deserialize, Serialize}; + +use super::schema::{accounts, assets, customers, fee_schedule, ledger_events, wallets, webhook_errors}; + +// -------------------------------------------------------------------- assets + +#[derive(Debug, Clone, Queryable, Selectable, Serialize)] +#[diesel(table_name = assets)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct Asset { + pub id: i32, + pub symbol: String, + pub network_id: String, + pub onramp_enabled: bool, + pub offramp_enabled: bool, + pub swap_enabled: bool, + pub sort_order: i32, + #[serde(skip)] + pub created_at: DateTime, + #[serde(skip)] + pub updated_at: DateTime, +} + +#[derive(Debug, Clone, Insertable, AsChangeset, Deserialize)] +#[diesel(table_name = assets)] +pub struct UpsertAsset { + pub symbol: String, + pub network_id: String, + #[serde(default)] + pub onramp_enabled: bool, + #[serde(default)] + pub offramp_enabled: bool, + #[serde(default)] + pub swap_enabled: bool, + #[serde(default)] + pub sort_order: i32, +} + +// -------------------------------------------------------------- fee schedule + +#[derive(Debug, Clone, Queryable, Selectable, Serialize)] +#[diesel(table_name = fee_schedule)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct FeeSchedule { + pub id: i32, + /// `manual` (admin-entered) or `dakota` (fetched). Surfaced to the UI so a + /// hand-typed rate is never displayed as if Dakota had confirmed it. + pub source: String, + pub transfer_fee_bps: Option, + pub ach_fee_cents: Option, + pub wire_fee_cents: Option, + pub sepa_fee_cents: Option, + pub swift_fee_cents: Option, + pub kyc_fee_cents: Option, + pub kyb_fee_cents: Option, + pub effective_from: DateTime, + pub fetched_at: DateTime, + pub note: Option, +} + +#[derive(Debug, Clone, Insertable, Deserialize)] +#[diesel(table_name = fee_schedule)] +pub struct NewFeeSchedule { + #[serde(default = "manual_source")] + pub source: String, + pub transfer_fee_bps: Option, + pub ach_fee_cents: Option, + pub wire_fee_cents: Option, + pub sepa_fee_cents: Option, + pub swift_fee_cents: Option, + pub kyc_fee_cents: Option, + pub kyb_fee_cents: Option, + pub note: Option, +} + +fn manual_source() -> String { + "manual".to_string() +} + +// ----------------------------------------------------------------- customers + +#[derive(Debug, Clone, Queryable, Selectable, Serialize)] +#[diesel(table_name = customers)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct Customer { + pub dakota_customer_id: String, + pub customer_type: String, + pub is_sub_client: bool, + pub sub_client_id: Option, + pub external_ref: Option, + pub application_id: Option, + pub kyb_status: Option, + pub kyc_status: Option, + pub application_status: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Clone, Insertable, AsChangeset)] +#[diesel(table_name = customers)] +pub struct UpsertCustomer { + pub dakota_customer_id: String, + pub customer_type: String, + pub is_sub_client: bool, + pub sub_client_id: Option, + pub external_ref: Option, + pub application_id: Option, + pub kyb_status: Option, + pub kyc_status: Option, + pub application_status: Option, +} + +// ------------------------------------------------------------------ accounts + +#[derive(Debug, Clone, Queryable, Selectable, Serialize)] +#[diesel(table_name = accounts)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct Account { + pub dakota_account_id: String, + pub dakota_customer_id: String, + pub account_type: String, + pub source_asset: Option, + pub source_network_id: Option, + pub destination_asset: Option, + pub destination_network_id: Option, + pub rail: Option, + pub created_at: DateTime, +} + +#[derive(Debug, Clone, Insertable)] +#[diesel(table_name = accounts)] +pub struct NewAccount { + pub dakota_account_id: String, + pub dakota_customer_id: String, + pub account_type: String, + pub source_asset: Option, + pub source_network_id: Option, + pub destination_asset: Option, + pub destination_network_id: Option, + pub rail: Option, +} + +// ------------------------------------------------------------------- ledger + +#[derive(Debug, Clone, Queryable, Selectable, Serialize)] +#[diesel(table_name = ledger_events)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct LedgerEvent { + pub event_id: String, + pub event_type: String, + pub resource_type: Option, + pub resource_id: Option, + pub dakota_customer_id: Option, + pub direction: Option, + pub amount_minor: Option, + pub asset: Option, + pub exchange_rate: Option, + pub fee_minor: Option, + pub status: Option, + pub occurred_at: Option>, + pub received_at: DateTime, +} + +#[derive(Debug, Clone, Insertable)] +#[diesel(table_name = ledger_events)] +pub struct NewLedgerEvent { + pub event_id: String, + pub event_type: String, + pub resource_type: Option, + pub resource_id: Option, + pub dakota_customer_id: Option, + pub direction: Option, + pub amount_minor: Option, + pub asset: Option, + pub exchange_rate: Option, + pub fee_minor: Option, + pub status: Option, + pub occurred_at: Option>, +} + +// ------------------------------------------------------------------ wallets + +#[derive(Debug, Clone, Queryable, Selectable, Serialize)] +#[diesel(table_name = wallets)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct Wallet { + pub dakota_wallet_id: String, + pub address: Option, + pub family: String, + pub signer_group_id: Option, + pub policy_id: Option, + pub label: Option, + pub created_at: DateTime, +} + +#[derive(Debug, Clone, Insertable)] +#[diesel(table_name = wallets)] +pub struct NewWallet { + pub dakota_wallet_id: String, + pub address: Option, + pub family: String, + pub signer_group_id: Option, + pub policy_id: Option, + pub label: Option, +} + +// ----------------------------------------------------------- webhook errors + +#[derive(Debug, Clone, Insertable)] +#[diesel(table_name = webhook_errors)] +pub struct NewWebhookError { + pub event_id: Option, + pub reason: String, + /// Digest of the body, never the body. A delivery that failed to parse is + /// exactly as likely to hold PII as one that succeeded. + pub body_sha256: String, +} diff --git a/rust-backend/services/dakota-service/src/db/repo.rs b/rust-backend/services/dakota-service/src/db/repo.rs new file mode 100644 index 00000000..ab265d82 --- /dev/null +++ b/rust-backend/services/dakota-service/src/db/repo.rs @@ -0,0 +1,324 @@ +//! Read-model queries. + +use std::sync::Arc; + +use anyhow::{Context, Result}; +use diesel::prelude::*; +use diesel::upsert::excluded; +use serde::Serialize; + +use super::models::*; +use super::schema::{accounts, assets, customers, fee_schedule, ledger_events, wallets, webhook_errors}; +use super::DbPool; + +#[derive(Clone)] +pub struct Repo { + pool: Arc, +} + +/// Per-customer rollup behind the flow-tracking views. +#[derive(Debug, Clone, Serialize, QueryableByName)] +pub struct CustomerFlow { + #[diesel(sql_type = diesel::sql_types::Text)] + pub dakota_customer_id: String, + #[diesel(sql_type = diesel::sql_types::Text)] + pub customer_type: String, + #[diesel(sql_type = diesel::sql_types::Nullable)] + pub sub_client_id: Option, + #[diesel(sql_type = diesel::sql_types::Nullable)] + pub asset: Option, + #[diesel(sql_type = diesel::sql_types::BigInt)] + pub events: i64, + #[diesel(sql_type = diesel::sql_types::Nullable)] + pub inbound_minor: Option, + #[diesel(sql_type = diesel::sql_types::Nullable)] + pub outbound_minor: Option, +} + +impl Repo { + pub fn new(pool: Arc) -> Self { + Self { pool } + } + + fn conn(&self) -> Result>> { + self.pool.get().context("checking out a db connection") + } + + // -------------------------------------------------------------- assets + + pub fn list_assets(&self) -> Result> { + let mut conn = self.conn()?; + assets::table + .order((assets::sort_order.asc(), assets::symbol.asc())) + .select(Asset::as_select()) + .load(&mut conn) + .context("listing assets") + } + + /// Insert or update by `(symbol, network_id)` — the natural key, so the + /// admin editing a row twice does not create a duplicate. + pub fn upsert_asset(&self, a: &UpsertAsset) -> Result { + let mut conn = self.conn()?; + diesel::insert_into(assets::table) + .values(a) + .on_conflict((assets::symbol, assets::network_id)) + .do_update() + .set(( + assets::onramp_enabled.eq(excluded(assets::onramp_enabled)), + assets::offramp_enabled.eq(excluded(assets::offramp_enabled)), + assets::swap_enabled.eq(excluded(assets::swap_enabled)), + assets::sort_order.eq(excluded(assets::sort_order)), + assets::updated_at.eq(diesel::dsl::now), + )) + .returning(Asset::as_returning()) + .get_result(&mut conn) + .context("upserting asset") + } + + pub fn delete_asset(&self, id: i32) -> Result { + let mut conn = self.conn()?; + diesel::delete(assets::table.find(id)) + .execute(&mut conn) + .context("deleting asset") + } + + /// Whether `(symbol, network)` is enabled for `flow`. + /// + /// This is the allow-list every ramp handler checks before calling Dakota: + /// the catalog is ours, so an asset we have not enabled must not be + /// reachable just because a caller typed it into a request body. + pub fn asset_allows(&self, symbol: &str, network_id: &str, flow: &str) -> Result { + let mut conn = self.conn()?; + let found: Option = assets::table + .filter(assets::symbol.eq(symbol)) + .filter(assets::network_id.eq(network_id)) + .select(Asset::as_select()) + .first(&mut conn) + .optional() + .context("checking asset")?; + Ok(match (found, flow) { + (Some(a), "onramp") => a.onramp_enabled, + (Some(a), "offramp") => a.offramp_enabled, + (Some(a), "swap") => a.swap_enabled, + _ => false, + }) + } + + // -------------------------------------------------------- fee schedule + + pub fn current_fees(&self) -> Result> { + let mut conn = self.conn()?; + fee_schedule::table + .order(fee_schedule::effective_from.desc()) + .select(FeeSchedule::as_select()) + .first(&mut conn) + .optional() + .context("loading fee schedule") + } + + /// Append a new schedule rather than mutating the old one, so a rate that + /// applied to a past transaction stays recoverable. + pub fn record_fees(&self, f: &NewFeeSchedule) -> Result { + let mut conn = self.conn()?; + diesel::insert_into(fee_schedule::table) + .values(f) + .returning(FeeSchedule::as_returning()) + .get_result(&mut conn) + .context("recording fee schedule") + } + + // ----------------------------------------------------------- customers + + pub fn upsert_customer(&self, c: &UpsertCustomer) -> Result { + let mut conn = self.conn()?; + diesel::insert_into(customers::table) + .values(c) + .on_conflict(customers::dakota_customer_id) + .do_update() + .set(( + customers::kyb_status.eq(excluded(customers::kyb_status)), + customers::kyc_status.eq(excluded(customers::kyc_status)), + customers::application_status.eq(excluded(customers::application_status)), + customers::application_id.eq(excluded(customers::application_id)), + customers::updated_at.eq(diesel::dsl::now), + )) + .returning(Customer::as_returning()) + .get_result(&mut conn) + .context("upserting customer") + } + + pub fn get_customer(&self, id: &str) -> Result> { + let mut conn = self.conn()?; + customers::table + .find(id) + .select(Customer::as_select()) + .first(&mut conn) + .optional() + .context("loading customer") + } + + /// List customers, optionally narrowed to one sub-client's roster. The + /// `sub_client` filter is how a business's session is confined to its own + /// customers. + pub fn list_customers(&self, sub_client: Option<&str>) -> Result> { + let mut conn = self.conn()?; + let mut q = customers::table.into_boxed(); + if let Some(sub) = sub_client { + q = q.filter(customers::sub_client_id.eq(sub.to_string())); + } + q.order(customers::created_at.desc()) + .select(Customer::as_select()) + .load(&mut conn) + .context("listing customers") + } + + pub fn list_sub_clients(&self) -> Result> { + let mut conn = self.conn()?; + customers::table + .filter(customers::is_sub_client.eq(true)) + .order(customers::created_at.desc()) + .select(Customer::as_select()) + .load(&mut conn) + .context("listing sub-clients") + } + + // ------------------------------------------------------------ accounts + + pub fn insert_account(&self, a: &NewAccount) -> Result { + let mut conn = self.conn()?; + diesel::insert_into(accounts::table) + .values(a) + .on_conflict(accounts::dakota_account_id) + .do_nothing() + .returning(Account::as_returning()) + .get_result(&mut conn) + .context("inserting account") + } + + pub fn list_accounts(&self, customer_id: Option<&str>) -> Result> { + let mut conn = self.conn()?; + let mut q = accounts::table.into_boxed(); + if let Some(c) = customer_id { + q = q.filter(accounts::dakota_customer_id.eq(c.to_string())); + } + q.order(accounts::created_at.desc()) + .select(Account::as_select()) + .load(&mut conn) + .context("listing accounts") + } + + /// Which customer an account belongs to — the scope check for any + /// account-addressed request. + pub fn account_owner(&self, account_id: &str) -> Result> { + let mut conn = self.conn()?; + accounts::table + .find(account_id) + .select(accounts::dakota_customer_id) + .first(&mut conn) + .optional() + .context("resolving account owner") + } + + // -------------------------------------------------------------- ledger + + /// Record an observation. Idempotent on `event_id`: Dakota retries + /// deliveries up to ~10 times over 48h, and a redelivery must not + /// double-count a transfer. + /// + /// Returns true when the row was new. + pub fn record_event(&self, e: &NewLedgerEvent) -> Result { + let mut conn = self.conn()?; + let inserted = diesel::insert_into(ledger_events::table) + .values(e) + .on_conflict(ledger_events::event_id) + .do_nothing() + .execute(&mut conn) + .context("recording ledger event")?; + Ok(inserted == 1) + } + + pub fn list_events(&self, customer_id: Option<&str>, limit: i64) -> Result> { + let mut conn = self.conn()?; + let mut q = ledger_events::table.into_boxed(); + if let Some(c) = customer_id { + q = q.filter(ledger_events::dakota_customer_id.eq(c.to_string())); + } + q.order(ledger_events::occurred_at.desc().nulls_last()) + .limit(limit.clamp(1, 500)) + .select(LedgerEvent::as_select()) + .load(&mut conn) + .context("listing ledger events") + } + + /// Per-customer, per-asset flow totals. + /// + /// `sub_client` narrows to one partner's roster, which is what a business + /// session sees; `None` is the platform-wide admin view. + pub fn customer_flows(&self, sub_client: Option<&str>) -> Result> { + use diesel::sql_types::{Nullable, Text}; + let mut conn = self.conn()?; + // Raw SQL: the conditional aggregates below have no clean diesel DSL + // equivalent, and this is a reporting query rather than a hot path. + diesel::sql_query( + r#" + SELECT c.dakota_customer_id, + c.customer_type, + c.sub_client_id, + e.asset, + COUNT(e.event_id) AS events, + -- ::bigint is load-bearing. Postgres widens SUM(bigint) to + -- NUMERIC, which does not match the BigInt this row binds to, + -- and the whole query fails at deserialization rather than in + -- the database. + SUM(CASE WHEN e.direction = 'in' THEN e.amount_minor END)::bigint AS inbound_minor, + SUM(CASE WHEN e.direction = 'out' THEN e.amount_minor END)::bigint AS outbound_minor + FROM customers c + LEFT JOIN ledger_events e ON e.dakota_customer_id = c.dakota_customer_id + WHERE ($1::text IS NULL OR c.sub_client_id = $1) + GROUP BY c.dakota_customer_id, c.customer_type, c.sub_client_id, e.asset + ORDER BY c.dakota_customer_id, e.asset + "#, + ) + .bind::, _>(sub_client) + .load::(&mut conn) + .context("aggregating customer flows") + } + + // ------------------------------------------------------------- wallets + + pub fn insert_wallet(&self, w: &NewWallet) -> Result { + let mut conn = self.conn()?; + diesel::insert_into(wallets::table) + .values(w) + .returning(Wallet::as_returning()) + .get_result(&mut conn) + .context("inserting wallet") + } + + pub fn list_wallets(&self) -> Result> { + let mut conn = self.conn()?; + wallets::table + .order(wallets::created_at.desc()) + .select(Wallet::as_select()) + .load(&mut conn) + .context("listing wallets") + } + + // ------------------------------------------------------ webhook errors + + pub fn record_webhook_error(&self, e: &NewWebhookError) -> Result<()> { + let mut conn = self.conn()?; + diesel::insert_into(webhook_errors::table) + .values(e) + .execute(&mut conn) + .context("recording webhook error")?; + Ok(()) + } + + /// Cheap liveness probe that actually touches Postgres. + pub fn ping(&self) -> Result<()> { + let mut conn = self.conn()?; + diesel::sql_query("SELECT 1").execute(&mut conn).context("pinging db")?; + Ok(()) + } +} diff --git a/rust-backend/services/dakota-service/src/db/schema.rs b/rust-backend/services/dakota-service/src/db/schema.rs new file mode 100644 index 00000000..53d4fa83 --- /dev/null +++ b/rust-backend/services/dakota-service/src/db/schema.rs @@ -0,0 +1,113 @@ +//! Diesel table definitions. Hand-written to match `migrations/000001_init`. + +diesel::table! { + assets (id) { + id -> Int4, + symbol -> Text, + network_id -> Text, + onramp_enabled -> Bool, + offramp_enabled -> Bool, + swap_enabled -> Bool, + sort_order -> Int4, + created_at -> Timestamptz, + updated_at -> Timestamptz, + } +} + +diesel::table! { + fee_schedule (id) { + id -> Int4, + source -> Text, + transfer_fee_bps -> Nullable, + ach_fee_cents -> Nullable, + wire_fee_cents -> Nullable, + sepa_fee_cents -> Nullable, + swift_fee_cents -> Nullable, + kyc_fee_cents -> Nullable, + kyb_fee_cents -> Nullable, + effective_from -> Timestamptz, + fetched_at -> Timestamptz, + note -> Nullable, + } +} + +diesel::table! { + customers (dakota_customer_id) { + dakota_customer_id -> Text, + customer_type -> Text, + is_sub_client -> Bool, + sub_client_id -> Nullable, + external_ref -> Nullable, + application_id -> Nullable, + kyb_status -> Nullable, + kyc_status -> Nullable, + application_status -> Nullable, + created_at -> Timestamptz, + updated_at -> Timestamptz, + } +} + +diesel::table! { + accounts (dakota_account_id) { + dakota_account_id -> Text, + dakota_customer_id -> Text, + account_type -> Text, + source_asset -> Nullable, + source_network_id -> Nullable, + destination_asset -> Nullable, + destination_network_id -> Nullable, + rail -> Nullable, + created_at -> Timestamptz, + } +} + +diesel::table! { + ledger_events (event_id) { + event_id -> Text, + event_type -> Text, + resource_type -> Nullable, + resource_id -> Nullable, + dakota_customer_id -> Nullable, + direction -> Nullable, + amount_minor -> Nullable, + asset -> Nullable, + exchange_rate -> Nullable, + fee_minor -> Nullable, + status -> Nullable, + occurred_at -> Nullable, + received_at -> Timestamptz, + } +} + +diesel::table! { + wallets (dakota_wallet_id) { + dakota_wallet_id -> Text, + address -> Nullable, + family -> Text, + signer_group_id -> Nullable, + policy_id -> Nullable, + label -> Nullable, + created_at -> Timestamptz, + } +} + +diesel::table! { + webhook_errors (id) { + id -> Int4, + event_id -> Nullable, + reason -> Text, + body_sha256 -> Text, + received_at -> Timestamptz, + } +} + +diesel::joinable!(accounts -> customers (dakota_customer_id)); +diesel::allow_tables_to_appear_in_same_query!( + assets, + fee_schedule, + customers, + accounts, + ledger_events, + wallets, + webhook_errors +); diff --git a/rust-backend/services/dakota-service/src/handlers/accounts.rs b/rust-backend/services/dakota-service/src/handlers/accounts.rs new file mode 100644 index 00000000..5da0ec5f --- /dev/null +++ b/rust-backend/services/dakota-service/src/handlers/accounts.rs @@ -0,0 +1,326 @@ +//! Ramps: recipients, destinations and onramp / offramp / swap accounts. +//! +//! All three ramps are one Dakota call (`POST /accounts`) with a different +//! `account_type`, but each has a prerequisite the API will not tell you about +//! until it rejects you: +//! +//! - the customer must be **KYB-approved** (`kyb_status == "active"`), for +//! individuals as much as businesses; +//! - an **onramp** must send `capabilities`, which is undocumented as required; +//! - an **offramp** needs a fiat destination, which needs a recipient *address*. +//! +//! We check what we can locally so the operator gets a sentence they can act +//! on instead of a 400 from three systems away. + +use std::sync::Arc; + +use auth_client::VerifiedClaims; +use axum::extract::{Path, Query, State}; +use axum::{Extension, Json}; +use serde::Deserialize; +use tracing::info; + +use super::{bad_request, internal, ApiError}; +use crate::authz::{authorize_customer, Caller}; +use crate::dakota::types::*; +use crate::db::models::{Account, NewAccount}; +use crate::state::AppState; + +// ---------------------------------------------------- recipients + destinations + +#[derive(Deserialize)] +pub struct CreateRecipientBody { + pub name: String, + /// Required before any fiat destination can be attached, so an offramp + /// needs it. Passed through to Dakota verbatim and never stored. + #[serde(default)] + pub address: Option, +} + +/// `POST /customers/:id/recipients` +pub async fn create_recipient( + State(state): State>, + Extension(claims): Extension, + Path(customer_id): Path, + Json(body): Json, +) -> Result, ApiError> { + let caller = Caller::from_claims(&claims)?; + authorize_customer(&state, &caller, &customer_id)?; + + state + .dakota + .post( + "POST /customers/{id}/recipients", + &format!("/customers/{customer_id}/recipients"), + &CreateRecipientReq { name: body.name, address: body.address }, + ) + .await + .map(Json) + .map_err(|e| (e.client_status(), e.to_string())) +} + +#[derive(Deserialize)] +pub struct CreateDestinationBody { + pub customer_id: String, + /// `crypto` | `fiat_us` | `fiat_iban` — Dakota discriminates on this. + pub destination_type: String, + #[serde(flatten)] + pub rest: serde_json::Value, +} + +/// `POST /recipients/:id/destinations` +/// +/// The body is relayed as-is: fiat destinations carry account and routing +/// numbers, and binding them to a struct here would be the first step toward +/// accidentally logging or storing them. +pub async fn create_destination( + State(state): State>, + Extension(claims): Extension, + Path(recipient_id): Path, + Json(body): Json, +) -> Result, ApiError> { + let caller = Caller::from_claims(&claims)?; + authorize_customer(&state, &caller, &body.customer_id)?; + + let mut payload = body.rest; + payload["destination_type"] = serde_json::json!(body.destination_type); + if let Some(network) = payload.get("network_id").and_then(|v| v.as_str()) { + if !state.cfg.network_allowed(network) { + return Err(bad_request(format!( + "network {network} is not permitted in this environment" + ))); + } + } + + state + .dakota + .post( + "POST /recipients/{id}/destinations", + &format!("/recipients/{recipient_id}/destinations"), + &payload, + ) + .await + .map(Json) + .map_err(|e| (e.client_status(), e.to_string())) +} + +// ------------------------------------------------------------------ accounts + +#[derive(Deserialize)] +pub struct CreateAccountBody { + pub customer_id: String, + /// `onramp` | `offramp` | `swap`. + pub account_type: String, + #[serde(default)] + pub crypto_destination_id: Option, + #[serde(default)] + pub fiat_destination_id: Option, + #[serde(default)] + pub source_asset: Option, + #[serde(default)] + pub destination_asset: Option, + #[serde(default)] + pub source_network_id: Option, + #[serde(default)] + pub destination_network_id: Option, + /// Defaults to ACH + Fedwire for onramps, which Dakota requires. + #[serde(default)] + pub capabilities: Option>, + #[serde(default)] + pub developer_fee_bps: Option, +} + +/// `POST /accounts` — open a ramp. +/// +/// Returns Dakota's response verbatim, because that is where the deposit +/// details live: `bank_account` (routing + account number + holder name) for an +/// onramp, `source_crypto_address` for an offramp or swap. The bank block is +/// PII and goes straight to the browser without touching the database. +pub async fn create_account( + State(state): State>, + Extension(claims): Extension, + Json(body): Json, +) -> Result, ApiError> { + let caller = Caller::from_claims(&claims)?; + let customer = authorize_customer(&state, &caller, &body.customer_id)?; + + // Fail here rather than let Dakota answer "Customer is not KYB-approved by + // Dakota", which reads like a business problem when it is usually just an + // un-run sandbox simulation. + if customer.kyb_status.as_deref() != Some("active") { + return Err(bad_request(format!( + "customer is not approved to transact (kyb_status = {}); \ + in sandbox, run the kyb_approve simulation first", + customer.kyb_status.as_deref().unwrap_or("unknown") + ))); + } + + match body.account_type.as_str() { + "onramp" | "offramp" | "swap" => {} + other => return Err(bad_request(format!("unknown account_type {other:?}"))), + } + + // The catalog is the allow-list: an asset we have not enabled must not be + // reachable just because someone typed it into a request body. + let (asset, network) = match body.account_type.as_str() { + // For an onramp the fiat side is the source; the stablecoin we deliver + // is what the catalog governs. + "onramp" => (body.destination_asset.as_deref(), body.destination_network_id.as_deref()), + _ => (body.source_asset.as_deref(), body.source_network_id.as_deref()), + }; + if let (Some(a), Some(n)) = (asset, network) { + if !state.cfg.network_allowed(n) { + return Err(bad_request(format!( + "network {n} is not permitted in this environment" + ))); + } + if !state + .repo + .asset_allows(a, n, &body.account_type) + .map_err(internal)? + { + return Err(bad_request(format!( + "{a} on {n} is not enabled for {}", + body.account_type + ))); + } + } + + let capabilities = match (body.account_type.as_str(), body.capabilities.clone()) { + (_, Some(c)) => Some(c), + // Undocumented as required; without it Dakota 400s "capabilities are + // required". + ("onramp", None) => Some(vec!["ach".to_string(), "fedwire".to_string()]), + _ => None, + }; + + let req = CreateAccountReq { + account_type: body.account_type.clone(), + capabilities, + crypto_destination_id: body.crypto_destination_id, + fiat_destination_id: body.fiat_destination_id, + destination_network_id: body.destination_network_id.clone(), + source_network_id: body.source_network_id.clone(), + source_asset: body.source_asset.clone(), + destination_asset: body.destination_asset.clone(), + developer_fee_bps: body.developer_fee_bps, + }; + + let raw: serde_json::Value = state + .dakota + .post("POST /accounts", "/accounts", &req) + .await + .map_err(|e| (e.client_status(), e.to_string()))?; + + // Index the non-identifying half. + if let Ok(summary) = serde_json::from_value::(raw.clone()) { + let _ = state.repo.insert_account(&NewAccount { + dakota_account_id: summary.id.clone(), + dakota_customer_id: body.customer_id.clone(), + account_type: summary.account_type.clone(), + source_asset: summary.source_asset.clone().or(body.source_asset), + source_network_id: summary.source_network_id.clone().or(body.source_network_id), + destination_asset: body.destination_asset, + destination_network_id: body.destination_network_id, + rail: summary.rail.clone(), + }); + info!( + account_id = %summary.id, + customer_id = %body.customer_id, + account_type = %summary.account_type, + "ramp account opened" + ); + } + + Ok(Json(raw)) +} + +#[derive(Deserialize)] +pub struct ListAccountsQuery { + #[serde(default)] + pub customer_id: Option, +} + +/// `GET /accounts` +pub async fn list_accounts( + State(state): State>, + Extension(claims): Extension, + Query(q): Query, +) -> Result>, ApiError> { + let caller = Caller::from_claims(&claims)?; + + let rows = match (&caller, q.customer_id.as_deref()) { + (_, Some(id)) => { + authorize_customer(&state, &caller, id)?; + state.repo.list_accounts(Some(id)).map_err(internal)? + } + (Caller::Admin, None) => state.repo.list_accounts(None).map_err(internal)?, + (Caller::Individual { customer_id }, None) => state + .repo + .list_accounts(Some(customer_id)) + .map_err(internal)?, + (Caller::Business { .. }, None) => { + // Accounts have no sub_client column; fan out over the roster so a + // business still gets one list rather than having to ask per + // customer. + let mut out = Vec::new(); + for c in state + .repo + .list_customers(caller.sub_client_filter()) + .map_err(internal)? + { + out.extend( + state + .repo + .list_accounts(Some(&c.dakota_customer_id)) + .map_err(internal)?, + ); + } + out + } + }; + Ok(Json(rows)) +} + +/// `GET /accounts/:id` — live detail from Dakota, including deposit +/// instructions. +pub async fn get_account( + State(state): State>, + Extension(claims): Extension, + Path(account_id): Path, +) -> Result, ApiError> { + let caller = Caller::from_claims(&claims)?; + let owner = state + .repo + .account_owner(&account_id) + .map_err(internal)? + .ok_or((axum::http::StatusCode::NOT_FOUND, "unknown account".to_string()))?; + authorize_customer(&state, &caller, &owner)?; + + state + .dakota + .get("GET /accounts/{id}", &format!("/accounts/{account_id}")) + .await + .map(Json) + .map_err(|e| (e.client_status(), e.to_string())) +} + +// ------------------------------------------------------------- transactions + +/// `GET /transactions` — auto-account transactions, relayed from Dakota. +/// +/// Admin-only: Dakota's list is not scoped per customer, and the payload +/// carries sender bank details. Non-admins read their own history from the +/// ledger via `/flows` instead. +pub async fn list_transactions( + State(state): State>, + Extension(claims): Extension, +) -> Result, ApiError> { + Caller::from_claims(&claims)?.require_admin()?; + state + .dakota + .get("GET /auto-transactions", "/auto-transactions") + .await + .map(Json) + .map_err(|e| (e.client_status(), e.to_string())) +} diff --git a/rust-backend/services/dakota-service/src/handlers/admin.rs b/rust-backend/services/dakota-service/src/handlers/admin.rs new file mode 100644 index 00000000..02fd7b98 --- /dev/null +++ b/rust-backend/services/dakota-service/src/handlers/admin.rs @@ -0,0 +1,346 @@ +//! Admin-only operations: sandbox simulation, webhook registration, resync. + +use std::sync::Arc; + +use auth_client::VerifiedClaims; +use axum::extract::State; +use axum::{Extension, Json}; +use serde::{Deserialize, Serialize}; +use tracing::{info, warn}; +use uuid::Uuid; + +use super::{bad_request, internal, ApiError}; +use crate::authz::{authorize_customer, Caller}; +use crate::dakota::types::*; +use crate::state::AppState; +use crate::webhook; + +// ------------------------------------------------------------------ sandbox + +#[derive(Deserialize)] +pub struct SimulateOnboardingBody { + pub customer_id: String, + /// Defaults to `kyb_approve`, which is the transition that actually moves + /// the state machine — including for individuals, where `kyc_approve` is a + /// no-op from `not_started`. + #[serde(default)] + pub r#type: Option, +} + +/// `POST /admin/sandbox/onboarding` — drive a customer to approved. +/// +/// Takes a **customer** id and looks up the application id itself: Dakota's +/// `applicant_id` field wants the application, and passing the customer id +/// (which the docs' example implies) silently does nothing. +pub async fn simulate_onboarding( + State(state): State>, + Extension(claims): Extension, + Json(body): Json, +) -> Result, ApiError> { + let caller = Caller::from_claims(&claims)?; + caller.require_admin()?; + let customer = authorize_customer(&state, &caller, &body.customer_id)?; + + let application_id = customer + .application_id + .clone() + .ok_or_else(|| bad_request("customer has no onboarding application"))?; + + let resp: SimulationResp = state + .dakota + .post( + "POST /sandbox/simulate/onboarding", + "/sandbox/simulate/onboarding", + &SimulateOnboardingReq { + r#type: body.r#type.unwrap_or_else(|| "kyb_approve".into()), + applicant_id: application_id, + simulation_id: Uuid::new_v4().to_string(), + }, + ) + .await + .map_err(|e| (e.client_status(), e.to_string()))?; + + // Refresh our copy of the status straight away. + // + // `POST /accounts` gates on the LOCAL `kyb_status`, and the webhook that + // would otherwise update it is asynchronous. Without this an operator + // clicks Approve, sees "approved", and the very next ramp is still refused + // as pending — which is exactly what the smoke test caught. Re-reading is + // one call and makes the button mean what it says. + if let Ok(fresh) = state + .dakota + .get::( + "GET /customers/{id}", + &format!("/customers/{}", body.customer_id), + ) + .await + { + let _ = state.repo.upsert_customer(&crate::db::models::UpsertCustomer { + dakota_customer_id: customer.dakota_customer_id.clone(), + customer_type: customer.customer_type.clone(), + is_sub_client: customer.is_sub_client, + sub_client_id: customer.sub_client_id.clone(), + external_ref: customer.external_ref.clone(), + application_id: fresh.application_id.clone(), + kyb_status: fresh.kyb_status.clone(), + kyc_status: fresh.kyc_status.clone(), + application_status: fresh.application_status.clone(), + }); + } + + info!( + customer_id = %body.customer_id, + previous = resp.previous_state.as_deref().unwrap_or("-"), + new = resp.new_state.as_deref().unwrap_or("-"), + "onboarding simulated" + ); + Ok(Json(resp)) +} + +#[derive(Deserialize)] +pub struct SimulateInboundBody { + /// `ach_inbound` | `fedwire_inbound` | `fednow_inbound` | `crypto_inbound`. + pub r#type: String, + /// Decimal string, e.g. "2.00". + pub amount: String, + #[serde(default = "usd")] + pub currency: String, + /// Required for fiat inbound types. + #[serde(default)] + pub account_id: Option, + /// Required for `crypto_inbound`. + #[serde(default)] + pub wallet_address: Option, + #[serde(default)] + pub scenario: Option, +} + +fn usd() -> String { + "USD".to_string() +} + +/// `POST /admin/sandbox/inbound` — fund a ramp without moving real money. +pub async fn simulate_inbound( + State(state): State>, + Extension(claims): Extension, + Json(body): Json, +) -> Result, ApiError> { + Caller::from_claims(&claims)?.require_admin()?; + + // Check the cap locally: Dakota's rejection is clear, but this saves a + // round-trip and states the limit in the same units the caller typed. + let minor = parse_minor(&body.amount) + .ok_or_else(|| bad_request(format!("amount {:?} is not a decimal string", body.amount)))?; + if minor > state.cfg.dakota.max_amount_minor { + return Err(bad_request(format!( + "amount {} exceeds the configured cap of {}.{:02}", + body.amount, + state.cfg.dakota.max_amount_minor / 100, + state.cfg.dakota.max_amount_minor % 100 + ))); + } + + state + .dakota + .post( + "POST /sandbox/simulate/inbound", + "/sandbox/simulate/inbound", + &SimulateInboundReq { + simulation_id: Uuid::new_v4().to_string(), + r#type: body.r#type, + amount: body.amount, + currency: body.currency, + account_id: body.account_id, + wallet_address: body.wallet_address, + scenario: body.scenario, + }, + ) + .await + .map(Json) + .map_err(|e| (e.client_status(), e.to_string())) +} + +/// Decimal string -> minor units. Mirrors `webhook::minor_units`; kept separate +/// because this one rejects rather than silently returning `None` downstream. +fn parse_minor(s: &str) -> Option { + let s = s.trim(); + let (whole, frac) = s.split_once('.').unwrap_or((s, "")); + if whole.is_empty() || !whole.chars().all(|c| c.is_ascii_digit()) { + return None; + } + if !frac.chars().all(|c| c.is_ascii_digit()) { + return None; + } + let units: i64 = whole.parse().ok()?; + let cents: i64 = frac + .chars() + .chain(std::iter::repeat('0')) + .take(2) + .collect::() + .parse() + .ok()?; + units.checked_mul(100)?.checked_add(cents) +} + +// ----------------------------------------------------------------- webhooks + +#[derive(Serialize)] +pub struct WebhookRegistration { + pub url: String, + pub result: serde_json::Value, +} + +/// `POST /admin/webhooks/register` — point Dakota at this deployment. +/// +/// Deliberately a manual action rather than something done at boot: registering +/// on every restart churns targets, and the URL depends on how the environment +/// is proxied rather than on anything the process can discover. +pub async fn register_webhook( + State(state): State>, + Extension(claims): Extension, +) -> Result, ApiError> { + Caller::from_claims(&claims)?.require_admin()?; + + let url = state + .cfg + .dakota + .webhook_url + .clone() + .ok_or_else(|| bad_request("dakota.webhook_url is not configured"))?; + + let result: serde_json::Value = state + .dakota + .post( + "POST /webhooks/targets", + "/webhooks/targets", + &CreateWebhookTargetReq { url: url.clone(), event_types: None }, + ) + .await + .map_err(|e| (e.client_status(), e.to_string()))?; + + info!(%url, "registered dakota webhook target"); + Ok(Json(WebhookRegistration { url, result })) +} + +/// `GET /admin/webhooks` — current targets, so an operator can see duplicates. +pub async fn list_webhooks( + State(state): State>, + Extension(claims): Extension, +) -> Result, ApiError> { + Caller::from_claims(&claims)?.require_admin()?; + state + .dakota + .get("GET /webhooks/targets", "/webhooks/targets") + .await + .map(Json) + .map_err(|e| (e.client_status(), e.to_string())) +} + +// ------------------------------------------------------------------- resync + +#[derive(Serialize)] +pub struct ResyncResult { + pub scanned: usize, + pub inserted: usize, + /// Dakota had more events than one page held. Surfaced so a partial + /// backfill is never mistaken for a complete one. + pub truncated: bool, +} + +/// `POST /admin/resync` — rebuild the ledger from Dakota's event log. +/// +/// Webhooks are the primary path, but they can be missed: a target registered +/// late, a deployment down past the 48-hour retry window, a delivery that +/// failed to parse. This replays `GET /events` through the same extractor, and +/// because `record_event` is keyed on the event id, replaying is safe. +pub async fn resync( + State(state): State>, + Extension(claims): Extension, +) -> Result, ApiError> { + Caller::from_claims(&claims)?.require_admin()?; + + // 100 is Dakota's hard maximum — asking for more is a 400, not a silent + // clamp ("Query parameter 'limit' has invalid value: number must be at + // most 100"). + let page: serde_json::Value = state + .dakota + .get("GET /events", "/events?limit=100") + .await + .map_err(|e| (e.client_status(), e.to_string()))?; + + let rows = page + .get("data") + .and_then(|d| d.as_array()) + .cloned() + .unwrap_or_default(); + + // One page only. Say so rather than letting a partial backfill look + // complete — a caller who reads "scanned 100" and stops has a gap they do + // not know about. + let truncated = page + .pointer("/meta/has_more_after") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + if truncated { + warn!( + scanned = rows.len(), + "dakota has more events than one page; run resync again to continue" + ); + } + + let mut inserted = 0usize; + for row in &rows { + // Dakota's event objects carry their own id; without one there is no + // idempotency key and replaying would duplicate the row. + let Some(event_id) = row + .get("id") + .or_else(|| row.get("event_id")) + .and_then(|v| v.as_str()) + else { + continue; + }; + let mut event = webhook::extract_for_resync(event_id, row); + if event.dakota_customer_id.is_none() { + if let Some(acct) = webhook::account_ref(row) { + event.dakota_customer_id = state.repo.account_owner(&acct).ok().flatten(); + } + } + if state.repo.record_event(&event).map_err(internal)? { + inserted += 1; + } + } + + info!(scanned = rows.len(), inserted, truncated, "resynced ledger from dakota events"); + Ok(Json(ResyncResult { scanned: rows.len(), inserted, truncated })) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_minor_handles_the_shapes_operators_type() { + assert_eq!(parse_minor("2"), Some(200)); + assert_eq!(parse_minor("2.00"), Some(200)); + assert_eq!(parse_minor("1.5"), Some(150)); + assert_eq!(parse_minor("0.05"), Some(5)); + assert_eq!(parse_minor(" 2.00 "), Some(200)); + } + + #[test] + fn parse_minor_rejects_nonsense() { + assert_eq!(parse_minor("abc"), None); + assert_eq!(parse_minor(""), None); + assert_eq!(parse_minor("-1.00"), None, "negatives are not deposits"); + assert_eq!(parse_minor("1.2.3"), None); + assert_eq!(parse_minor("$2.00"), None); + } + + #[test] + fn sandbox_cap_boundary() { + // $2.00 is the documented sandbox ceiling: allowed, and a cent more is + // not. + assert!(parse_minor("2.00").unwrap() <= 200); + assert!(parse_minor("2.01").unwrap() > 200); + } +} diff --git a/rust-backend/services/dakota-service/src/handlers/catalog.rs b/rust-backend/services/dakota-service/src/handlers/catalog.rs new file mode 100644 index 00000000..03b054df --- /dev/null +++ b/rust-backend/services/dakota-service/src/handlers/catalog.rs @@ -0,0 +1,138 @@ +//! Supported-asset catalog and rates. +//! +//! Dakota exposes neither. `/capabilities/networks` returns bare network id +//! strings with no asset information, and `GET /self-serve/credits/pricing` +//! 403s for our client tier ("Credit management is only available for +//! self-serve customers"). So the catalog is ours to curate, and the fee +//! schedule is admin-entered. +//! +//! Realised rates are a different matter: every transaction receipt carries an +//! `exchange_rate` and a fee breakdown, so `GET /rates` reports what we were +//! actually charged alongside what we expected to be. + +use std::sync::Arc; + +use auth_client::VerifiedClaims; +use axum::extract::{Path, State}; +use axum::{Extension, Json}; +use serde::Serialize; + +use super::{internal, ApiError}; +use crate::authz::Caller; +use crate::db::models::{Asset, FeeSchedule, NewFeeSchedule, UpsertAsset}; +use crate::state::AppState; + +#[derive(Serialize)] +pub struct CatalogResp { + pub assets: Vec, + /// Networks Dakota accepts, as reported by `/capabilities/networks`, + /// intersected with what our config permits. Sandbox lists mainnets it + /// then refuses, so the intersection is the honest answer. + pub networks: Vec, +} + +/// `GET /catalog` — everything the ramp forms need to render. Readable by any +/// authenticated caller; it describes our offering, not anyone's data. +pub async fn get_catalog(State(state): State>) -> Result, ApiError> { + let assets = state.repo.list_assets().map_err(internal)?; + + // Best-effort: a Dakota outage should degrade the dropdown, not break the + // page. Falling back to the configured allow-list keeps it usable. + let networks = match state + .dakota + .get::>("GET /capabilities/networks", "/capabilities/networks") + .await + { + Ok(all) => all + .into_iter() + .filter(|n| state.cfg.network_allowed(n)) + .collect(), + Err(e) => { + tracing::warn!(error = %e, "falling back to the configured network list"); + state.cfg.dakota.allowed_networks.clone() + } + }; + + Ok(Json(CatalogResp { assets, networks })) +} + +/// `PUT /admin/assets` — add or update one `(symbol, network)` entry. +pub async fn upsert_asset( + State(state): State>, + Extension(claims): Extension, + Json(req): Json, +) -> Result, ApiError> { + Caller::from_claims(&claims)?.require_admin()?; + + if !state.cfg.network_allowed(&req.network_id) { + return Err(super::bad_request(format!( + "network {} is not permitted in this environment", + req.network_id + ))); + } + state.repo.upsert_asset(&req).map(Json).map_err(internal) +} + +/// `DELETE /admin/assets/:id` +pub async fn delete_asset( + State(state): State>, + Extension(claims): Extension, + Path(id): Path, +) -> Result { + Caller::from_claims(&claims)?.require_admin()?; + let removed = state.repo.delete_asset(id).map_err(internal)?; + Ok(if removed == 0 { + axum::http::StatusCode::NOT_FOUND + } else { + axum::http::StatusCode::NO_CONTENT + }) +} + +#[derive(Serialize)] +pub struct RatesResp { + /// What we expect to be charged. `source` is `manual` unless Dakota ever + /// opens the pricing endpoint to us — surfaced so the UI can say so rather + /// than implying Dakota confirmed these numbers. + pub schedule: Option, + /// What we were actually charged, newest first, derived from receipts. + pub realised: Vec, +} + +#[derive(Serialize)] +pub struct RealisedRate { + pub asset: Option, + pub exchange_rate: Option, + pub fee_minor: Option, + pub amount_minor: Option, + pub occurred_at: Option, +} + +/// `GET /rates` +pub async fn get_rates(State(state): State>) -> Result, ApiError> { + let schedule = state.repo.current_fees().map_err(internal)?; + let realised = state + .repo + .list_events(None, 50) + .map_err(internal)? + .into_iter() + .filter(|e| e.exchange_rate.is_some()) + .map(|e| RealisedRate { + asset: e.asset, + exchange_rate: e.exchange_rate, + fee_minor: e.fee_minor, + amount_minor: e.amount_minor, + occurred_at: e.occurred_at.map(|t| t.to_rfc3339()), + }) + .collect(); + Ok(Json(RatesResp { schedule, realised })) +} + +/// `POST /admin/rates` — record the expected fee schedule. +pub async fn set_rates( + State(state): State>, + Extension(claims): Extension, + Json(req): Json, +) -> Result, ApiError> { + Caller::from_claims(&claims)?.require_admin()?; + state.repo.record_fees(&req).map(Json).map_err(internal) +} diff --git a/rust-backend/services/dakota-service/src/handlers/customers.rs b/rust-backend/services/dakota-service/src/handlers/customers.rs new file mode 100644 index 00000000..2d7f8c63 --- /dev/null +++ b/rust-backend/services/dakota-service/src/handlers/customers.rs @@ -0,0 +1,265 @@ +//! Customer creation and onboarding. +//! +//! Onboarding is **hosted redirect only**. We send Dakota a name, a type and +//! our own reference, and get back an `application_url`; the customer completes +//! beneficial owners, documents, SSNs and attestations on Dakota's form. None +//! of it passes through this service, which is what makes the no-PII policy +//! real rather than aspirational. +//! +//! What we persist is the skeleton: ids, type, hierarchy and status. Names are +//! relayed from Dakota per-request and never written down. + +use std::sync::Arc; + +use auth_client::VerifiedClaims; +use axum::extract::{Path, State}; +use axum::{Extension, Json}; +use serde::{Deserialize, Serialize}; +use tracing::info; + +use super::{internal, ApiError}; +use crate::authz::{authorize_customer, creation_sub_client, Caller}; +use crate::dakota::types::*; +use crate::db::models::{Customer, UpsertCustomer}; +use crate::invites::Invite; +use crate::state::AppState; + +#[derive(Deserialize)] +pub struct CreateCustomerBody { + pub name: String, + /// `business` | `individual`. + pub customer_type: String, + #[serde(default)] + pub external_ref: Option, + /// Make this customer a partner business with its own roster beneath it. + /// Immutable after creation, and mutually exclusive with `sub_client_id`. + #[serde(default)] + pub is_sub_client: bool, + /// File this customer under a partner business. Ignored for business + /// callers, who may only create beneath themselves. + #[serde(default)] + pub sub_client_id: Option, + /// Also mint a signup link so the customer can reach their own dashboard. + #[serde(default)] + pub with_invite: bool, +} + +#[derive(Serialize)] +pub struct CreateCustomerResult { + pub customer: Customer, + /// Dakota's hosted onboarding form. Send the customer here — everything + /// sensitive is collected on the far side. + pub application_url: String, + /// Signup grant for our dashboard, when asked for. The dashboard turns + /// this into `/signup?invite=…`. + #[serde(skip_serializing_if = "Option::is_none")] + pub invite: Option, +} + +/// `POST /customers` +pub async fn create_customer( + State(state): State>, + Extension(claims): Extension, + Json(body): Json, +) -> Result, ApiError> { + let caller = Caller::from_claims(&claims)?; + + if body.is_sub_client && !caller.is_admin() { + return Err(super::bad_request("only an admin can create a partner business")); + } + // Taken from the caller's token, not the body — this is what stops one + // business filing customers under another. + let sub_client_id = creation_sub_client(&caller, body.sub_client_id.as_deref())?; + if body.is_sub_client && sub_client_id.is_some() { + return Err(super::bad_request( + "is_sub_client and sub_client_id are mutually exclusive", + )); + } + + let created: CreateCustomerResp = state + .dakota + .post( + "POST /customers", + "/customers", + &CreateCustomerReq { + name: body.name, + customer_type: body.customer_type.clone(), + external_id: body.external_ref.clone(), + is_sub_client: body.is_sub_client.then_some(true), + sub_client_id: sub_client_id.clone(), + }, + ) + .await + .map_err(|e| (e.client_status(), e.to_string()))?; + + let customer = state + .repo + .upsert_customer(&UpsertCustomer { + dakota_customer_id: created.id.clone(), + customer_type: body.customer_type, + is_sub_client: body.is_sub_client, + sub_client_id, + external_ref: body.external_ref, + application_id: Some(created.application_id.clone()), + // Dakota starts everyone here; the real value arrives by webhook. + kyb_status: Some("pending".into()), + kyc_status: Some("not_started".into()), + application_status: Some("not_started".into()), + }) + .map_err(internal)?; + + let invite = if body.with_invite { + let role = if body.is_sub_client { "business" } else { "individual" }; + Some( + state + .invites + .mint(role, Some(&created.id), Some(&format!("{role} onboarding"))) + .await + .map_err(internal)?, + ) + } else { + None + }; + + info!( + customer_id = %created.id, + is_sub_client = body.is_sub_client, + "customer created; handing off to hosted onboarding" + ); + Ok(Json(CreateCustomerResult { + customer, + application_url: created.application_url, + invite, + })) +} + +/// `GET /customers` — the roster this caller may see. +/// +/// An individual sees only itself; a business sees its own customers; an admin +/// sees everyone. +pub async fn list_customers( + State(state): State>, + Extension(claims): Extension, +) -> Result>, ApiError> { + let caller = Caller::from_claims(&claims)?; + let rows = match &caller { + Caller::Individual { customer_id } => state + .repo + .get_customer(customer_id) + .map_err(internal)? + .into_iter() + .collect(), + _ => state + .repo + .list_customers(caller.sub_client_filter()) + .map_err(internal)?, + }; + Ok(Json(rows)) +} + +/// `GET /admin/sub-clients` — partner businesses, with Dakota's own rollup. +pub async fn list_sub_clients( + State(state): State>, + Extension(claims): Extension, +) -> Result, ApiError> { + Caller::from_claims(&claims)?.require_admin()?; + + let local = state.repo.list_sub_clients().map_err(internal)?; + // Dakota's summary carries `sub_client_name` — relayed for display, never + // stored. + let remote: serde_json::Value = state + .dakota + .get( + "GET /customers/sub-client-summary", + "/customers/sub-client-summary", + ) + .await + .map_err(|e| (e.client_status(), e.to_string()))?; + + Ok(Json(serde_json::json!({ "sub_clients": local, "summary": remote }))) +} + +/// `GET /customers/:id` — live detail, straight from Dakota. +/// +/// Returns Dakota's body untouched (including `name`, which we never store) so +/// the dashboard can display a human-readable record without us keeping one. +pub async fn get_customer( + State(state): State>, + Extension(claims): Extension, + Path(customer_id): Path, +) -> Result, ApiError> { + let caller = Caller::from_claims(&claims)?; + let local = authorize_customer(&state, &caller, &customer_id)?; + + let remote: serde_json::Value = state + .dakota + .get("GET /customers/{id}", &format!("/customers/{customer_id}")) + .await + .map_err(|e| (e.client_status(), e.to_string()))?; + + // Refresh our status skeleton off the authoritative copy while we have it. + if let Ok(status) = serde_json::from_value::(remote.clone()) { + let _ = state.repo.upsert_customer(&UpsertCustomer { + dakota_customer_id: local.dakota_customer_id.clone(), + customer_type: local.customer_type.clone(), + is_sub_client: local.is_sub_client, + sub_client_id: local.sub_client_id.clone(), + external_ref: local.external_ref.clone(), + application_id: status.application_id.clone(), + kyb_status: status.kyb_status.clone(), + kyc_status: status.kyc_status.clone(), + application_status: status.application_status.clone(), + }); + } + + Ok(Json(remote)) +} + +/// `GET /customers/:id/capabilities` — what this customer can do and what is +/// still blocking them. Dakota returns requirement rows with a hosted `url` +/// per item, which is exactly what the dashboard's "finish onboarding" panel +/// links to. +pub async fn get_capabilities( + State(state): State>, + Extension(claims): Extension, + Path(customer_id): Path, +) -> Result, ApiError> { + let caller = Caller::from_claims(&claims)?; + authorize_customer(&state, &caller, &customer_id)?; + + state + .dakota + .get( + "GET /customers/{id}/capabilities", + &format!("/customers/{customer_id}/capabilities"), + ) + .await + .map(Json) + .map_err(|e| (e.client_status(), e.to_string())) +} + +/// `POST /customers/:id/invite` — mint a fresh signup link. +/// +/// A business uses this to onboard its own customers; an admin for anyone. +pub async fn create_invite( + State(state): State>, + Extension(claims): Extension, + Path(customer_id): Path, +) -> Result, ApiError> { + let caller = Caller::from_claims(&claims)?; + let customer = authorize_customer(&state, &caller, &customer_id)?; + if matches!(caller, Caller::Individual { .. }) { + return Err(( + axum::http::StatusCode::FORBIDDEN, + "individuals cannot mint invites".into(), + )); + } + + let role = if customer.is_sub_client { "business" } else { "individual" }; + state + .invites + .mint(role, Some(&customer_id), Some(&format!("{role} onboarding"))) + .await + .map(Json) + .map_err(internal) +} diff --git a/rust-backend/services/dakota-service/src/handlers/flows.rs b/rust-backend/services/dakota-service/src/handlers/flows.rs new file mode 100644 index 00000000..b4d986cb --- /dev/null +++ b/rust-backend/services/dakota-service/src/handlers/flows.rs @@ -0,0 +1,196 @@ +//! Activity and amount-flow tracking. +//! +//! Everything here reads the local `ledger_events` table rather than Dakota, +//! for three reasons: it is already scoped to our hierarchy, it aggregates +//! without N round-trips, and it contains no PII — Dakota's own +//! `GET /events` payload carries sender names and bank account numbers. + +use std::sync::Arc; + +use auth_client::VerifiedClaims; +use axum::extract::{Path, Query, State}; +use axum::{Extension, Json}; +use serde::{Deserialize, Serialize}; + +use super::{internal, ApiError}; +use crate::authz::{authorize_customer, Caller}; +use crate::db::models::LedgerEvent; +use crate::db::repo::CustomerFlow; +use crate::state::AppState; + +#[derive(Deserialize)] +pub struct FeedQuery { + #[serde(default = "default_limit")] + pub limit: i64, +} + +fn default_limit() -> i64 { + 100 +} + +#[derive(Serialize)] +pub struct FlowsResp { + /// Per-customer, per-asset totals for whatever the caller may see. + pub by_customer: Vec, + /// Platform- or roster-wide totals per asset. + pub totals: Vec, +} + +#[derive(Serialize, Default)] +pub struct AssetTotal { + pub asset: String, + pub inbound_minor: i64, + pub outbound_minor: i64, + pub events: i64, +} + +/// `GET /flows` — the tracking view. +/// +/// Admin sees the platform; a business sees its own roster; an individual sees +/// only itself. +pub async fn get_flows( + State(state): State>, + Extension(claims): Extension, +) -> Result, ApiError> { + let caller = Caller::from_claims(&claims)?; + + let by_customer = match &caller { + Caller::Individual { customer_id } => state + .repo + .customer_flows(None) + .map_err(internal)? + .into_iter() + .filter(|f| &f.dakota_customer_id == customer_id) + .collect(), + _ => state + .repo + .customer_flows(caller.sub_client_filter()) + .map_err(internal)?, + }; + + Ok(Json(FlowsResp { + totals: totals_by_asset(&by_customer), + by_customer, + })) +} + +/// `GET /flows/:customer_id` — one customer's timeline. +pub async fn customer_feed( + State(state): State>, + Extension(claims): Extension, + Path(customer_id): Path, + Query(q): Query, +) -> Result>, ApiError> { + let caller = Caller::from_claims(&claims)?; + authorize_customer(&state, &caller, &customer_id)?; + state + .repo + .list_events(Some(&customer_id), q.limit) + .map(Json) + .map_err(internal) +} + +/// `GET /flows/feed` — recent activity across everything the caller may see. +pub async fn feed( + State(state): State>, + Extension(claims): Extension, + Query(q): Query, +) -> Result>, ApiError> { + let caller = Caller::from_claims(&claims)?; + let rows = match &caller { + Caller::Admin => state.repo.list_events(None, q.limit).map_err(internal)?, + Caller::Individual { customer_id } => state + .repo + .list_events(Some(customer_id), q.limit) + .map_err(internal)?, + Caller::Business { .. } => { + // Filter the roster in memory. Fine at sandbox volumes; if this + // ever gets slow the fix is an index-backed IN query, not a + // per-customer fan-out. + let roster: std::collections::HashSet = state + .repo + .list_customers(caller.sub_client_filter()) + .map_err(internal)? + .into_iter() + .map(|c| c.dakota_customer_id) + .collect(); + state + .repo + .list_events(None, q.limit) + .map_err(internal)? + .into_iter() + .filter(|e| { + e.dakota_customer_id + .as_deref() + .is_some_and(|id| roster.contains(id)) + }) + .collect() + } + }; + Ok(Json(rows)) +} + +/// Roll per-customer rows up per asset. +/// +/// `NULL` asset rows come from the LEFT JOIN — a customer with no activity — +/// and are skipped so a brand-new customer does not invent an empty asset. +fn totals_by_asset(rows: &[CustomerFlow]) -> Vec { + let mut acc: std::collections::BTreeMap = Default::default(); + for r in rows { + let Some(asset) = r.asset.clone() else { continue }; + let e = acc.entry(asset.clone()).or_insert_with(|| AssetTotal { + asset, + ..Default::default() + }); + e.inbound_minor += r.inbound_minor.unwrap_or(0); + e.outbound_minor += r.outbound_minor.unwrap_or(0); + e.events += r.events; + } + acc.into_values().collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn row(cus: &str, asset: Option<&str>, inb: Option, outb: Option, n: i64) -> CustomerFlow { + CustomerFlow { + dakota_customer_id: cus.into(), + customer_type: "individual".into(), + sub_client_id: None, + asset: asset.map(|s| s.into()), + events: n, + inbound_minor: inb, + outbound_minor: outb, + } + } + + #[test] + fn totals_sum_per_asset_across_customers() { + let t = totals_by_asset(&[ + row("c1", Some("USDC"), Some(200), None, 1), + row("c2", Some("USDC"), Some(150), Some(50), 2), + row("c3", Some("RD"), None, Some(75), 1), + ]); + assert_eq!(t.len(), 2); + let usdc = t.iter().find(|a| a.asset == "USDC").unwrap(); + assert_eq!(usdc.inbound_minor, 350); + assert_eq!(usdc.outbound_minor, 50); + assert_eq!(usdc.events, 3); + let rd = t.iter().find(|a| a.asset == "RD").unwrap(); + assert_eq!(rd.outbound_minor, 75); + assert_eq!(rd.inbound_minor, 0); + } + + #[test] + fn customers_with_no_activity_do_not_invent_an_asset() { + // The LEFT JOIN emits a NULL-asset row for a customer with no events. + let t = totals_by_asset(&[row("c1", None, None, None, 0)]); + assert!(t.is_empty()); + } + + #[test] + fn empty_input_is_empty_output() { + assert!(totals_by_asset(&[]).is_empty()); + } +} diff --git a/rust-backend/services/dakota-service/src/handlers/mod.rs b/rust-backend/services/dakota-service/src/handlers/mod.rs new file mode 100644 index 00000000..cfdb5429 --- /dev/null +++ b/rust-backend/services/dakota-service/src/handlers/mod.rs @@ -0,0 +1,42 @@ +//! HTTP handlers. +//! +//! Every handler that touches customer data resolves a [`Caller`] from the +//! verified JWT first and scopes off that — never off a path or body field. + +pub mod accounts; +pub mod admin; +pub mod catalog; +pub mod customers; +pub mod flows; +pub mod wallets; + +use std::sync::Arc; + +use axum::extract::State; +use axum::http::StatusCode; + +use crate::state::AppState; + +pub type ApiError = (StatusCode, String); + +pub async fn health() -> &'static str { + "ok" +} + +/// Readiness that actually touches Postgres, so a wedged pool fails the health +/// gate instead of reporting green. +pub async fn ready(State(state): State>) -> Result<&'static str, ApiError> { + state + .repo + .ping() + .map(|_| "ok") + .map_err(|e| (StatusCode::SERVICE_UNAVAILABLE, e.to_string())) +} + +pub fn internal(e: E) -> ApiError { + (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()) +} + +pub fn bad_request(e: E) -> ApiError { + (StatusCode::BAD_REQUEST, e.to_string()) +} diff --git a/rust-backend/services/dakota-service/src/handlers/wallets.rs b/rust-backend/services/dakota-service/src/handlers/wallets.rs new file mode 100644 index 00000000..7e0c1917 --- /dev/null +++ b/rust-backend/services/dakota-service/src/handlers/wallets.rs @@ -0,0 +1,427 @@ +//! Treasury: the admin's non-custodial Dakota wallet. +//! +//! Setup is a five-call chain that has to run in order, because each step +//! references the last: +//! +//! ```text +//! POST /signers (our P-256 public key) +//! └─▶ POST /signer-groups (member_keys = the PUBLIC KEY, not the signer id) +//! └─▶ POST /policies +//! └─▶ POST /wallets (signer_groups + policies) +//! ``` +//! +//! Sending is an endorsed request: we sign a canonical intent with the private +//! half. See [`crate::wallet`] for why the canonicalization is exact. +//! +//! Admin-only throughout. This is our own treasury, not a per-customer wallet. + +use std::sync::Arc; + +use auth_client::VerifiedClaims; +use axum::extract::{Path, State}; +use axum::{Extension, Json}; +use serde::{Deserialize, Serialize}; +use tracing::{error, info}; +use uuid::Uuid; + +use super::{bad_request, internal, ApiError}; +use crate::authz::Caller; +use crate::db::models::{NewWallet, Wallet}; +use crate::state::AppState; +use crate::wallet::{normalize_amount, SendTransactionIntent, TransferOperation}; + +/// Dakota addresses chains by CAIP-2, but its network ids are its own strings, +/// and nothing in the API converts between them. Wrong chain id means the +/// transfer either fails or — worse — targets a chain the operator did not +/// mean, so this map is explicit rather than derived. +fn caip2_for(network_id: &str) -> Option<&'static str> { + Some(match network_id { + "ethereum-sepolia" => "eip155:11155111", + "base-sepolia" => "eip155:84532", + "arbitrum-sepolia" => "eip155:421614", + "optimism-sepolia" => "eip155:11155420", + "polygon-amoy" => "eip155:80002", + "solana-devnet" => "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + _ => return None, + }) +} + +fn signer(state: &Arc) -> Result<&crate::wallet::WalletSigner, ApiError> { + state.wallet_signer.as_ref().ok_or_else(|| { + bad_request( + "no treasury key configured — set dakota.wallet_p256_pem in the secrets file", + ) + }) +} + +// -------------------------------------------------------------------- setup + +#[derive(Deserialize)] +pub struct SetupBody { + #[serde(default = "default_label")] + pub label: String, + /// `evm` | `solana`. One wallet per family; an EVM wallet's address is + /// shared across every EVM chain. + #[serde(default = "default_family")] + pub family: String, +} + +fn default_label() -> String { + "treasury".to_string() +} +fn default_family() -> String { + "evm".to_string() +} + +#[derive(Serialize)] +pub struct SetupResult { + pub wallet: Wallet, + pub signer_id: String, +} + +/// `POST /admin/treasury/setup` — run the whole chain and record the result. +/// +/// Not idempotent on Dakota's side: calling it twice creates a second wallet. +/// It is an explicit admin action for exactly that reason. +pub async fn setup( + State(state): State>, + Extension(claims): Extension, + Json(body): Json, +) -> Result, ApiError> { + Caller::from_claims(&claims)?.require_admin()?; + let signer = signer(&state)?; + let public_key = signer.public_key_b64().map_err(internal)?; + + #[derive(Deserialize)] + struct Created { + id: String, + } + + let registered: Created = state + .dakota + .post( + "POST /signers", + "/signers", + &serde_json::json!({ + "name": format!("{}-signer", body.label), + "public_key": public_key, + "key_type": "ES256", + }), + ) + .await + .map_err(|e| (e.client_status(), e.to_string()))?; + + // `member_keys` takes public keys, not signer ids — passing the id here is + // accepted and produces a group that can never authorize anything. + let group: Created = state + .dakota + .post( + "POST /signer-groups", + "/signer-groups", + &serde_json::json!({ + "name": format!("{}-group", body.label), + "member_keys": [public_key], + }), + ) + .await + .map_err(|e| (e.client_status(), e.to_string()))?; + + let policy: Created = state + .dakota + .post( + "POST /policies", + "/policies", + &serde_json::json!({ + "name": format!("{}-policy", body.label), + "description": "single-approval treasury policy", + "signer_group_id": group.id, + // One approval: we hold exactly one key. Raising this without + // registering more signers would lock the wallet. + "rules": [{ + "rule_type": "approval_threshold", + "action": "allow", + "definition": { "threshold": 1 } + }], + }), + ) + .await + .map_err(|e| (e.client_status(), e.to_string()))?; + + #[derive(Deserialize)] + struct CreatedWallet { + id: String, + #[serde(default)] + address: Option, + } + + let created: CreatedWallet = state + .dakota + .post( + "POST /wallets", + "/wallets", + &serde_json::json!({ + "name": body.label, + "family": body.family, + "signer_groups": [group.id], + "policies": [policy.id], + }), + ) + .await + .map_err(|e| (e.client_status(), e.to_string()))?; + + let wallet = state + .repo + .insert_wallet(&NewWallet { + dakota_wallet_id: created.id.clone(), + address: created.address.clone(), + family: body.family, + signer_group_id: Some(group.id), + policy_id: Some(policy.id), + label: Some(body.label), + }) + .map_err(internal)?; + + info!( + wallet_id = %created.id, + address = created.address.as_deref().unwrap_or("-"), + "treasury wallet created" + ); + Ok(Json(SetupResult { wallet, signer_id: registered.id })) +} + +/// `GET /admin/treasury` — wallets we know about, each with live balances. +pub async fn list( + State(state): State>, + Extension(claims): Extension, +) -> Result, ApiError> { + Caller::from_claims(&claims)?.require_admin()?; + + let wallets = state.repo.list_wallets().map_err(internal)?; + let mut out = Vec::with_capacity(wallets.len()); + for w in wallets { + // Best-effort per wallet: one unreachable balance should not blank the + // whole treasury page. + let balances = state + .dakota + .get::( + "GET /wallets/{id}/balances", + &format!("/wallets/{}/balances", w.dakota_wallet_id), + ) + .await + .unwrap_or_else(|e| serde_json::json!({ "error": e.to_string() })); + out.push(serde_json::json!({ "wallet": w, "balances": balances })); + } + Ok(Json(serde_json::json!({ "treasury": out }))) +} + +/// `GET /admin/treasury/:id/balances` +pub async fn balances( + State(state): State>, + Extension(claims): Extension, + Path(wallet_id): Path, +) -> Result, ApiError> { + Caller::from_claims(&claims)?.require_admin()?; + state + .dakota + .get( + "GET /wallets/{id}/balances", + &format!("/wallets/{wallet_id}/balances"), + ) + .await + .map(Json) + .map_err(|e| (e.client_status(), e.to_string())) +} + +// --------------------------------------------------------------------- send + +#[derive(Deserialize)] +pub struct SendBody { + pub to: String, + /// Decimal string, e.g. "1.50". + pub amount: String, + pub asset_id: String, + /// Our network id; converted to CAIP-2 before signing. + pub network_id: String, +} + +/// `POST /admin/treasury/:id/send` — sign and submit a transfer. +pub async fn send( + State(state): State>, + Extension(claims): Extension, + Path(wallet_id): Path, + Json(body): Json, +) -> Result, ApiError> { + Caller::from_claims(&claims)?.require_admin()?; + let signer = signer(&state)?; + + let wallet = state + .repo + .list_wallets() + .map_err(internal)? + .into_iter() + .find(|w| w.dakota_wallet_id == wallet_id) + .ok_or_else(|| bad_request("unknown treasury wallet"))?; + let from = wallet + .address + .clone() + .ok_or_else(|| bad_request("treasury wallet has no recorded address"))?; + + if !state.cfg.network_allowed(&body.network_id) { + return Err(bad_request(format!( + "network {} is not permitted in this environment", + body.network_id + ))); + } + let caip2 = caip2_for(&body.network_id) + .ok_or_else(|| bad_request(format!("no CAIP-2 mapping for {}", body.network_id)))?; + + let minor = parse_minor(&body.amount) + .ok_or_else(|| bad_request(format!("amount {:?} is not a decimal string", body.amount)))?; + if minor > state.cfg.dakota.max_amount_minor { + return Err(bad_request(format!( + "amount {} exceeds the configured cap of {}.{:02}", + body.amount, + state.cfg.dakota.max_amount_minor / 100, + state.cfg.dakota.max_amount_minor % 100 + ))); + } + + let intent = SendTransactionIntent { + wallet_id: wallet_id.clone(), + caip2: caip2.to_string(), + operation: TransferOperation { + kind: "transfer".into(), + from, + to: body.to.clone(), + // Must be Dakota's normalized form or the signature will not + // verify — see `wallet::normalize_amount`. + amount: normalize_amount(&body.amount), + asset_id: body.asset_id.clone(), + }, + idempotency_key: Uuid::new_v4().to_string(), + }; + let endorsed = signer.endorse(intent).map_err(internal)?; + + // Post the envelope as a `Value`, not as the struct. + // + // `serde_json::Value` orders its keys, so this transmits the whole request + // in canonical form. Sending the struct instead puts `signatures` before + // `intent` (declaration order) and Dakota answers `endorsement validation + // failed` — verified against the live sandbox, where the identical intent + // and key succeed one way and fail the other. + let envelope = serde_json::to_value(&endorsed).map_err(internal)?; + tracing::debug!( + wire = %serde_json::to_string(&envelope).unwrap_or_default(), + "endorsed request" + ); + + match state + .dakota + .post::<_, serde_json::Value>( + "POST /wallets/{id}/transactions", + &format!("/wallets/{wallet_id}/transactions"), + &envelope, + ) + .await + { + Ok(resp) => { + info!( + %wallet_id, + to = %body.to, + amount = %body.amount, + asset = %body.asset_id, + "treasury transfer submitted" + ); + Ok(Json(resp)) + } + Err(e) => { + // Per docs/tx-alerting.md, a submission failure alerts at the + // service handler rather than inside the client. Insufficient + // balance and policy rejection are expected outcomes of a + // human-driven action, not incidents — only a genuine submission + // failure pages. + let detail = e.to_string(); + let benign = detail.contains("insufficient") + || detail.contains("policy") + || e.client_status() == axum::http::StatusCode::BAD_REQUEST; + if !benign { + error!( + alert_id = "tx-failed-dakota-wallet", + %wallet_id, + asset = %body.asset_id, + amount = %body.amount, + dakota_request_id = e.request_id().unwrap_or("-"), + error = %detail, + "treasury transfer submission failed" + ); + } + Err((e.client_status(), detail)) + } + } +} + +/// Decimal string -> minor units. Rejects negatives: a transfer is not a +/// refund, and a negative would sail under the cap check. +fn parse_minor(s: &str) -> Option { + let s = s.trim(); + let (whole, frac) = s.split_once('.').unwrap_or((s, "")); + if whole.is_empty() || !whole.chars().all(|c| c.is_ascii_digit()) { + return None; + } + if !frac.chars().all(|c| c.is_ascii_digit()) { + return None; + } + let units: i64 = whole.parse().ok()?; + let cents: i64 = frac + .chars() + .chain(std::iter::repeat('0')) + .take(2) + .collect::() + .parse() + .ok()?; + units.checked_mul(100)?.checked_add(cents) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn caip2_covers_every_sandbox_network() { + // These are exactly the networks config.staging.toml allows; a missing + // entry here would make a permitted network un-sendable. + for n in [ + "ethereum-sepolia", + "base-sepolia", + "arbitrum-sepolia", + "optimism-sepolia", + "polygon-amoy", + "solana-devnet", + ] { + assert!(caip2_for(n).is_some(), "no CAIP-2 mapping for {n}"); + } + } + + #[test] + fn caip2_values_are_the_real_chain_ids() { + assert_eq!(caip2_for("base-sepolia"), Some("eip155:84532")); + assert_eq!(caip2_for("ethereum-sepolia"), Some("eip155:11155111")); + assert_eq!(caip2_for("polygon-amoy"), Some("eip155:80002")); + } + + #[test] + fn unknown_network_has_no_mapping() { + // Better to refuse than to guess a chain id and send somewhere real. + assert_eq!(caip2_for("ethereum-mainnet"), None); + assert_eq!(caip2_for("nonsense"), None); + } + + #[test] + fn parse_minor_rejects_negatives_and_garbage() { + assert_eq!(parse_minor("1.50"), Some(150)); + assert_eq!(parse_minor("2"), Some(200)); + assert_eq!(parse_minor("-1.00"), None); + assert_eq!(parse_minor("abc"), None); + assert_eq!(parse_minor(""), None); + } +} diff --git a/rust-backend/services/dakota-service/src/invites.rs b/rust-backend/services/dakota-service/src/invites.rs new file mode 100644 index 00000000..639ada74 --- /dev/null +++ b/rust-backend/services/dakota-service/src/invites.rs @@ -0,0 +1,67 @@ +//! Client for auth-service's internal invite-minting route. +//! +//! Direction of dependency matters: dakota-service calls auth-service, never +//! the reverse. auth-service stays domain-agnostic — it stores an opaque +//! `scope_id` and has no idea it means a Dakota customer. + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +#[derive(Clone)] +pub struct InviteClient { + base_url: String, + ttl_secs: i64, + http: reqwest::Client, +} + +#[derive(Debug, Serialize)] +struct CreateInviteReq<'a> { + role: &'a str, + scope_id: Option<&'a str>, + label: Option<&'a str>, + ttl_secs: i64, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Invite { + pub invite_id: String, + pub role: String, + pub expires_at: String, +} + +impl InviteClient { + pub fn new(base_url: impl Into, ttl_secs: i64) -> Self { + Self { + base_url: base_url.into().trim_end_matches('/').to_string(), + ttl_secs, + http: reqwest::Client::new(), + } + } + + /// Mint a signup grant for `role`, scoped to a Dakota customer id. + /// + /// `label` is shown on the signup page, so keep it non-identifying — it is + /// the one free-text field that crosses into auth-service. + pub async fn mint( + &self, + role: &str, + scope_id: Option<&str>, + label: Option<&str>, + ) -> Result { + let url = format!("{}/invites", self.base_url); + let body = CreateInviteReq { role, scope_id, label, ttl_secs: self.ttl_secs }; + + let resp = observability::client::instrumented("auth-service", "POST /invites", |h| { + self.http.post(&url).headers(h).json(&body).send() + }) + .await + .context("calling auth-service /invites")?; + + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + if !status.is_success() { + anyhow::bail!("auth-service /invites → {status}: {text}"); + } + serde_json::from_str(&text).context("parsing invite response") + } +} diff --git a/rust-backend/services/dakota-service/src/lib.rs b/rust-backend/services/dakota-service/src/lib.rs new file mode 100644 index 00000000..db55beba --- /dev/null +++ b/rust-backend/services/dakota-service/src/lib.rs @@ -0,0 +1,83 @@ +//! dakota-service. +//! +//! Fronts the [Dakota](https://docs.dakota.xyz) stablecoin on/off-ramp platform +//! for our admin, partner-business and individual-customer dashboards. +//! +//! ## Hierarchy +//! +//! Dakota's object model carries the three tiers directly: +//! +//! ```text +//! us (Client) ─┬─ partner business (Customer, is_sub_client: true) +//! │ └─ its customers (Customer, sub_client_id: ) +//! └─ our own customers (Customer, no sub_client_id) +//! ``` +//! +//! A JWT's `role` + `scope` decides which slice of that a caller sees; see +//! [`authz`]. Scope is read only from the verified token, never from a request. +//! +//! ## No PII +//! +//! 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 of it is +//! persisted.** We store Dakota KSUIDs, enums, amounts, assets and timestamps; +//! anything identifying is fetched per-request and relayed straight to the +//! browser. Handlers that need to show a name return `serde_json::Value` +//! rather than binding a struct, so there is nothing to accidentally write. +//! +//! Onboarding follows from that: customers are handed to Dakota's hosted +//! `application_url`, and beneficial owners, documents and SSNs never touch +//! this code. +//! +//! ## Staging only +//! +//! This service is declared in `docker-compose.staging.yml` and deliberately +//! absent from the prod compose file, which is what keeps `deploy.sh` from ever +//! planning it into prod. + +pub mod authz; +pub mod config; +pub mod dakota; +pub mod db; +pub mod handlers; +pub mod invites; +pub mod router; +pub mod state; +pub mod wallet; +pub mod webhook; + +pub use config::Config; +pub use state::AppState; + +use std::path::PathBuf; + +use clap::Parser; + +#[derive(Parser, Debug)] +#[command( + name = "dakota-service", + about = "Dakota on/off-ramp integration: customers, ramps, treasury and flow tracking." +)] +pub struct Cli { + #[arg(short, long, default_value = "services/dakota-service/config/config.toml")] + pub config: PathBuf, + + /// Secrets TOML holding `dakota.api_key`. No env-var fallback. + #[arg( + short = 's', + long, + default_value = "services/dakota-service/config/secrets.toml" + )] + pub secrets: PathBuf, +} + +cli_spec::define_program! { + id = "dakota-service", + cargo_pkg = "dakota-service", + working_dir = ".", + description = "Dakota stablecoin on/off-ramp integration. Hosted-redirect onboarding, \ + onramp/offramp/swap accounts, Ed25519-verified webhooks and a PII-free \ + activity ledger for admin, partner-business and individual dashboards.", + cli = crate::Cli, +} diff --git a/rust-backend/services/dakota-service/src/main.rs b/rust-backend/services/dakota-service/src/main.rs new file mode 100644 index 00000000..984b962a --- /dev/null +++ b/rust-backend/services/dakota-service/src/main.rs @@ -0,0 +1,95 @@ +//! dakota-service binary. +//! +//! Boot order follows the house pattern: logging → config (`${VAR}` expansion) +//! → DB pool + embedded migrations → secrets (the Dakota API key — a hard +//! requirement, nothing works without it) → clients → axum serve. + +use std::sync::Arc; + +use anyhow::{Context, Result}; +use auth_client::AuthClient; +use clap::Parser; +use tracing::{info, warn}; + +use dakota_service::dakota::DakotaClient; +use dakota_service::db::{establish_pool, repo::Repo, run_migrations}; +use dakota_service::invites::InviteClient; +use dakota_service::state::AppState; +use dakota_service::wallet::WalletSigner; +use dakota_service::{router, webhook, Cli, Config}; + +#[tokio::main] +async fn main() -> Result<()> { + let _obs = observability::init("dakota-service"); + + let cli = Cli::parse(); + let cfg_path = cli.config.to_string_lossy().into_owned(); + info!(cfg_path, "loading config"); + let cfg = Config::load(&cfg_path).with_context(|| format!("loading config from {cfg_path}"))?; + + let pool = Arc::new(establish_pool(&cfg.database_url, cfg.db_pool_size)?); + run_migrations(&pool).context("running dakota-service DB migrations")?; + let repo = Repo::new(Arc::clone(&pool)); + info!(pool_size = cfg.db_pool_size, "dakota-service DB ready (migrations applied)"); + + let secrets = runtime_config::Secrets::load(&cli.secrets) + .with_context(|| format!("loading secrets {}", cli.secrets.display()))?; + let api_key = secrets + .dakota_api_key() + .context("resolving the dakota api key")?; + + // Parsed at boot so a malformed key is a startup failure rather than a + // silent flood of rejected deliveries hours later. + let webhook_key = webhook::parse_verifying_key(&cfg.dakota.webhook_public_key) + .context("parsing dakota.webhook_public_key")?; + + let dakota = DakotaClient::new(&cfg.dakota.base_url, api_key); + let auth = Arc::new(AuthClient::new(cfg.auth.internal_url.clone())); + let invites = InviteClient::new(cfg.auth.internal_url.clone(), cfg.auth.invite_ttl_secs); + + if cfg.dakota.webhook_url.is_none() { + warn!( + "dakota.webhook_url is unset — POST /admin/webhooks/register will fail and no \ + events will arrive until it is configured" + ); + } + if cfg.dakota.allowed_networks.is_empty() { + warn!( + "dakota.allowed_networks is empty — every network Dakota reports will be offered, \ + including mainnets the sandbox then refuses" + ); + } + + let bind_addr = cfg.bind_addr; + let origins = cfg.allowed_origins.clone(); + info!( + environment = %cfg.environment, + dakota = %cfg.dakota.base_url, + max_amount_minor = cfg.dakota.max_amount_minor, + "dakota-service starting" + ); + + // Optional: only the treasury needs it, and a service with no treasury key + // is still fully useful for onboarding and ramps. + let wallet_signer = match secrets.dakota_wallet_p256_pem() { + Some(pem) => { + let s = WalletSigner::from_pem(pem).context("parsing dakota.wallet_p256_pem")?; + info!(public_key = %s.public_key_b64()?, "treasury signing key loaded"); + Some(s) + } + None => { + warn!("dakota.wallet_p256_pem is unset — the treasury endpoints will refuse"); + None + } + }; + + let state = Arc::new(AppState::new( + cfg, + repo, + dakota, + webhook_key, + invites, + wallet_signer, + )); + router::serve(bind_addr, state, auth, &origins).await +} diff --git a/rust-backend/services/dakota-service/src/router.rs b/rust-backend/services/dakota-service/src/router.rs new file mode 100644 index 00000000..59ac7fd9 --- /dev/null +++ b/rust-backend/services/dakota-service/src/router.rs @@ -0,0 +1,116 @@ +//! The public router. +//! +//! Three tiers: +//! +//! - **open** — health, and the webhook receiver. The webhook cannot carry our +//! JWT; it authenticates itself with an Ed25519 signature instead. +//! - **authenticated** — everything customer-facing. `require_auth` verifies +//! the token with auth-service and inserts the claims; each handler then +//! scopes off those claims. +//! - **admin** — control plane, gated by `require_admin` so a business or +//! individual token cannot reach it even if it guesses the path. +//! +//! The admin routes also re-check `require_admin()` inside their handlers. +//! That is deliberate belt-and-braces: the layer is easy to drop when adding a +//! route, and these are the operations where being wrong is expensive. + +use std::net::SocketAddr; +use std::sync::Arc; + +use anyhow::Result; +use auth_client::AuthClient; +use axum::routing::{delete, get, post, put}; +use axum::Router; +use tower_http::cors::{Any, CorsLayer}; +use tracing::info; + +use crate::handlers::{accounts, admin, catalog, customers, flows, wallets}; +use crate::state::AppState; +use crate::webhook; + +pub fn build(state: Arc, auth: Arc, allowed_origins: &[String]) -> Result { + let cors = build_cors(allowed_origins)?; + + let open = Router::new() + .route("/health", get(crate::handlers::ready)) + .route("/webhooks/dakota", post(webhook::receive)); + + let authed = Router::new() + .route("/catalog", get(catalog::get_catalog)) + .route("/rates", get(catalog::get_rates)) + .route("/customers", get(customers::list_customers).post(customers::create_customer)) + .route("/customers/:id", get(customers::get_customer)) + .route("/customers/:id/capabilities", get(customers::get_capabilities)) + .route("/customers/:id/invite", post(customers::create_invite)) + .route("/customers/:id/recipients", post(accounts::create_recipient)) + .route("/recipients/:id/destinations", post(accounts::create_destination)) + .route("/accounts", get(accounts::list_accounts).post(accounts::create_account)) + .route("/accounts/:id", get(accounts::get_account)) + .route("/flows", get(flows::get_flows)) + .route("/flows/feed", get(flows::feed)) + .route("/flows/:customer_id", get(flows::customer_feed)) + .route_layer(axum::middleware::from_fn_with_state( + Arc::clone(&auth), + auth_client::require_auth, + )); + + let admin_routes = Router::new() + .route("/admin/assets", put(catalog::upsert_asset)) + .route("/admin/assets/:id", delete(catalog::delete_asset)) + .route("/admin/rates", post(catalog::set_rates)) + .route("/admin/sub-clients", get(customers::list_sub_clients)) + .route("/admin/transactions", get(accounts::list_transactions)) + .route("/admin/sandbox/onboarding", post(admin::simulate_onboarding)) + .route("/admin/sandbox/inbound", post(admin::simulate_inbound)) + .route("/admin/webhooks", get(admin::list_webhooks)) + .route("/admin/webhooks/register", post(admin::register_webhook)) + .route("/admin/resync", post(admin::resync)) + .route("/admin/treasury", get(wallets::list)) + .route("/admin/treasury/setup", post(wallets::setup)) + .route("/admin/treasury/:id/balances", get(wallets::balances)) + .route("/admin/treasury/:id/send", post(wallets::send)) + .route_layer(axum::middleware::from_fn_with_state( + auth, + auth_client::require_admin, + )); + + Ok(open + .merge(authed) + .merge(admin_routes) + .with_state(state) + .merge(observability::middleware::metrics_route()) + .layer(axum::middleware::from_fn( + observability::middleware::http_obs, + )) + .layer(cors)) +} + +pub async fn serve( + addr: SocketAddr, + state: Arc, + auth: Arc, + allowed_origins: &[String], +) -> Result<()> { + let app = build(state, auth, allowed_origins)?; + let listener = tokio::net::TcpListener::bind(addr).await?; + info!(%addr, "dakota-service listening"); + axum::serve(listener, app).await?; + Ok(()) +} + +fn build_cors(allowed_origins: &[String]) -> Result { + if allowed_origins.iter().any(|o| o == "*") { + return Ok(CorsLayer::new() + .allow_origin(Any) + .allow_methods(Any) + .allow_headers(Any)); + } + let mut origins = Vec::with_capacity(allowed_origins.len()); + for o in allowed_origins { + origins.push(o.parse()?); + } + Ok(CorsLayer::new() + .allow_origin(origins) + .allow_methods(Any) + .allow_headers(Any)) +} diff --git a/rust-backend/services/dakota-service/src/state.rs b/rust-backend/services/dakota-service/src/state.rs new file mode 100644 index 00000000..ce1074d8 --- /dev/null +++ b/rust-backend/services/dakota-service/src/state.rs @@ -0,0 +1,36 @@ +//! Shared application state. + +use ed25519_dalek::VerifyingKey; + +use crate::config::Config; +use crate::dakota::DakotaClient; +use crate::db::repo::Repo; +use crate::invites::InviteClient; +use crate::wallet::WalletSigner; + +pub struct AppState { + pub cfg: Config, + pub repo: Repo, + pub dakota: DakotaClient, + /// Verifies webhook deliveries. Parsed once at boot so a malformed key is + /// a startup failure rather than a silent flood of rejected deliveries. + pub webhook_key: VerifyingKey, + pub invites: InviteClient, + /// Treasury signing key. `None` when `dakota.wallet_p256_pem` is unset — + /// every other feature works without it, so a missing key degrades the + /// treasury rather than blocking startup. + pub wallet_signer: Option, +} + +impl AppState { + pub fn new( + cfg: Config, + repo: Repo, + dakota: DakotaClient, + webhook_key: VerifyingKey, + invites: InviteClient, + wallet_signer: Option, + ) -> Self { + Self { cfg, repo, dakota, webhook_key, invites, wallet_signer } + } +} diff --git a/rust-backend/services/dakota-service/src/wallet/live_tests.rs b/rust-backend/services/dakota-service/src/wallet/live_tests.rs new file mode 100644 index 00000000..a36c4c84 --- /dev/null +++ b/rust-backend/services/dakota-service/src/wallet/live_tests.rs @@ -0,0 +1,186 @@ +//! Live sandbox check for the signing chain. +//! +//! Unit tests can only prove we verify our *own* signatures. The thing that +//! actually breaks in production is a canonicalization mismatch: Dakota +//! re-canonicalizes the intent server-side, and if their bytes differ from +//! ours by one character the signature fails to verify — with an error that +//! says nothing about canonicalization. Only the real API can tell us. +//! +//! Run with a sandbox key: +//! +//! ```sh +//! DAKOTA_TEST_API_KEY=... cargo test -p dakota-service -- --ignored live +//! ``` +//! +//! Creates a throwaway signer/group/policy/wallet each run (Dakota has no +//! delete for these, so they accumulate in the sandbox — harmless). + +use super::*; +use p256::ecdsa::SigningKey; +use p256::pkcs8::EncodePrivateKey; +use serde_json::Value; + +const BASE: &str = "https://api.platform.sandbox.dakota.xyz"; + +fn api_key() -> String { + std::env::var("DAKOTA_TEST_API_KEY").expect("set DAKOTA_TEST_API_KEY to run live tests") +} + +async fn post(path: &str, key: &str, body: &Value) -> (u16, Value) { + let resp = reqwest::Client::new() + .post(format!("{BASE}{path}")) + .header("x-api-key", key) + .header("x-idempotency-key", uuid::Uuid::new_v4().to_string()) + .header("content-type", "application/json") + .json(body) + .send() + .await + .expect("request"); + let status = resp.status().as_u16(); + let text = resp.text().await.unwrap_or_default(); + let json = serde_json::from_str(&text).unwrap_or(Value::String(text)); + (status, json) +} + +/// End-to-end: register our public key, build a wallet around it, then submit +/// a signed transfer and assert Dakota accepted the **signature**. +/// +/// The transfer itself is expected to fail — the wallet is empty. That is the +/// point: an empty-balance rejection proves the signature verified and policy +/// evaluation was reached, whereas a signature error would mean our canonical +/// form disagrees with Dakota's. +#[tokio::test] +#[ignore] // requires DAKOTA_TEST_API_KEY +async fn live_signature_is_accepted_by_dakota() { + let key = api_key(); + + // Fresh key per run so the test never depends on prior state. + let sk = SigningKey::random(&mut rand::rngs::OsRng); + let pem = sk.to_pkcs8_pem(p256::pkcs8::LineEnding::LF).unwrap().to_string(); + let signer = WalletSigner::from_pem(&pem).unwrap(); + let public_key = signer.public_key_b64().unwrap(); + + let (st, signer_resp) = post( + "/signers", + &key, + &serde_json::json!({ "name": "live-test", "public_key": public_key, "key_type": "ES256" }), + ) + .await; + assert_eq!(st, 201, "POST /signers → {signer_resp}"); + // Dakota echoes the key type back in its own spelling. + assert_eq!(signer_resp["key_type"], "KEY_TYPE_ES256"); + + // member_keys takes PUBLIC KEYS, not signer ids. + let (st, group) = post( + "/signer-groups", + &key, + &serde_json::json!({ "name": "live-test-group", "member_keys": [public_key] }), + ) + .await; + assert_eq!(st, 201, "POST /signer-groups → {group}"); + let group_id = group["id"].as_str().unwrap().to_string(); + + let (st, policy) = post( + "/policies", + &key, + &serde_json::json!({ + "name": "live-test-policy", + "signer_group_id": group_id, + "rules": [{ "rule_type": "approval_threshold", "action": "allow", + "definition": { "threshold": 1 } }], + }), + ) + .await; + assert_eq!(st, 201, "POST /policies → {policy}"); + + let (st, wallet) = post( + "/wallets", + &key, + &serde_json::json!({ + "name": "live-test-wallet", "family": "evm", + "signer_groups": [group_id], "policies": [policy["id"].as_str().unwrap()], + }), + ) + .await; + assert_eq!(st, 201, "POST /wallets → {wallet}"); + let wallet_id = wallet["id"].as_str().unwrap().to_string(); + let address = wallet["address"].as_str().unwrap().to_string(); + + // Balances must be readable on a brand-new wallet. + let bal = reqwest::Client::new() + .get(format!("{BASE}/wallets/{wallet_id}/balances")) + .header("x-api-key", &key) + .send() + .await + .unwrap(); + assert_eq!(bal.status().as_u16(), 200); + + // The actual subject of the test. + let intent = SendTransactionIntent { + wallet_id: wallet_id.clone(), + caip2: "eip155:84532".into(), + operation: TransferOperation { + kind: "transfer".into(), + from: address.clone(), + to: "0x000000000000000000000000000000000000dEaD".into(), + amount: "0.01".into(), + asset_id: "USDC".into(), + }, + idempotency_key: uuid::Uuid::new_v4().to_string(), + }; + let endorsed = signer.endorse(intent).unwrap(); + let body = serde_json::to_value(&endorsed).unwrap(); + + let (status, resp) = post( + &format!("/wallets/{wallet_id}/transactions"), + &key, + &body, + ) + .await; + + let detail = resp["detail"].as_str().unwrap_or("").to_lowercase(); + let title = resp["title"].as_str().unwrap_or("").to_lowercase(); + let combined = format!("{title} {detail}"); + + // The one outcome that means our canonicalization is wrong. + assert!( + !combined.contains("signature") + && !combined.contains("endorse") + && !combined.contains("unauthorized signer"), + "Dakota rejected the SIGNATURE — canonical form disagrees with theirs.\n\ + status {status}, body: {resp}" + ); + + // Anything else (accepted, or refused for balance/policy reasons) means + // the signature verified and Dakota got as far as evaluating the transfer. + println!("live signing OK — status {status}, body: {resp}"); + + // --- amount normalization ------------------------------------------- + // + // Dakota normalizes the decimal before rebuilding the intent it verifies + // against, so a signature over "1.00" is checked against "1" and fails. + // Without `normalize_amount` every whole-dollar transfer the dashboard + // sends is rejected as "endorsement validation failed", which names + // nothing useful. These are the exact forms a person types. + for raw in ["1.00", "0.50", "2.00", "0.01"] { + let intent = SendTransactionIntent { + wallet_id: wallet_id.clone(), + caip2: "eip155:84532".into(), + operation: TransferOperation { + kind: "transfer".into(), + from: address.clone(), + to: "0x000000000000000000000000000000000000dEaD".into(), + amount: crate::wallet::normalize_amount(raw), + asset_id: "USDC".into(), + }, + idempotency_key: uuid::Uuid::new_v4().to_string(), + }; + let body = serde_json::to_value(&signer.endorse(intent).unwrap()).unwrap(); + let (_, resp) = post(&format!("/wallets/{wallet_id}/transactions"), &key, &body).await; + let detail = resp["detail"].as_str().unwrap_or("").to_lowercase(); + assert!( + !detail.contains("endorsement"), + "amount {raw:?} was rejected as an endorsement failure: {resp}" + ); + } +} diff --git a/rust-backend/services/dakota-service/src/wallet/mod.rs b/rust-backend/services/dakota-service/src/wallet/mod.rs new file mode 100644 index 00000000..cdf07f68 --- /dev/null +++ b/rust-backend/services/dakota-service/src/wallet/mod.rs @@ -0,0 +1,386 @@ +//! Dakota wallet intents: canonicalization and ES256 signing. +//! +//! Dakota's wallets are non-custodial. We hold an ECDSA P-256 key; Dakota holds +//! only its public half, registered as an `ES256` signer. Every privileged +//! wallet or policy operation is an **endorsed request** — a signed statement of +//! intent — rather than a plain API call. +//! +//! The chain is exact and unforgiving: +//! +//! ```text +//! intent ──RFC 8785 JCS──▶ canonical bytes ──SHA-256──▶ digest +//! ──ECDSA P-256──▶ signature ──ASN.1 DER──▶ base64 +//! ``` +//! +//! Dakota **re-canonicalizes server-side** before verifying, so if our +//! canonical form differs from theirs by a single byte the signature simply +//! fails to verify and the error says nothing about why. Three rules keep the +//! two in step, and all three are enforced by construction below: +//! +//! - `snake_case` field names; +//! - amounts as **strings**, never JSON numbers (`"1.50"`, not `1.5`); +//! - unset fields **omitted**, never serialized as `null`. +//! +//! Nine endpoints take an endorsed request, not just transaction submission — +//! attaching or detaching a policy or signer group is equally privileged. See +//! [`Intent`]. + +use anyhow::{Context, Result}; +use base64::Engine; +use p256::ecdsa::signature::Signer; +use p256::ecdsa::{DerSignature, SigningKey}; +use p256::pkcs8::DecodePrivateKey; +use serde::Serialize; + +/// The treasury signing key. +pub struct WalletSigner { + key: SigningKey, +} + +/// A signed statement of intent, in the shape Dakota's `EndorsedRequest` +/// expects. +#[derive(Debug, Clone, Serialize)] +pub struct EndorsedRequest { + /// Base64 ASN.1 DER ECDSA signatures over the canonical intent. One per + /// signer; a policy's `approval_threshold` decides how many are needed. + pub signatures: Vec, + pub intent: T, +} + +/// `transfer` operation inside a [`SendTransactionIntent`]. +#[derive(Debug, Clone, Serialize)] +pub struct TransferOperation { + /// Always `"transfer"` for a send. + pub kind: String, + pub from: String, + pub to: String, + /// Decimal **string**. A JSON number here would canonicalize differently + /// on each side and break the signature. + pub amount: String, + pub asset_id: String, +} + +/// Intent for `POST /wallets/{id}/transactions`. +#[derive(Debug, Clone, Serialize)] +pub struct SendTransactionIntent { + pub wallet_id: String, + /// CAIP-2 chain id, e.g. `eip155:84532` for Base Sepolia. + pub caip2: String, + pub operation: TransferOperation, + pub idempotency_key: String, +} + +impl WalletSigner { + /// Load from a PKCS#8 PEM private key (what `openssl ... -genkey` writes). + pub fn from_pem(pem: &str) -> Result { + let key = SigningKey::from_pkcs8_pem(pem.trim()) + .context("parsing the P-256 wallet key (expected a PKCS#8 PEM private key)")?; + Ok(Self { key }) + } + + /// Base64 DER SubjectPublicKeyInfo — exactly what `POST /signers` wants as + /// `public_key` alongside `key_type: "ES256"`. + pub fn public_key_b64(&self) -> Result { + use p256::pkcs8::EncodePublicKey; + let der = self + .key + .verifying_key() + .to_public_key_der() + .context("encoding the P-256 public key")?; + Ok(base64::engine::general_purpose::STANDARD.encode(der.as_bytes())) + } + + /// Canonicalize, hash and sign an intent. + /// + /// The SHA-256 step is implicit: `p256`'s ECDSA signer prehashes with + /// SHA-256, which is what ES256 means. + pub fn sign(&self, intent: &T) -> Result { + let canonical = canonicalize(intent)?; + let sig: DerSignature = self.key.sign(&canonical); + Ok(base64::engine::general_purpose::STANDARD.encode(sig.as_bytes())) + } + + /// Sign an intent and wrap it for submission **in its canonical form**. + /// + /// The returned `intent` is deliberately a `serde_json::Value` rebuilt from + /// the canonical bytes, not the original struct. `serde_json::Value` orders + /// object keys, so re-serializing it reproduces the exact bytes that were + /// signed. + /// + /// This is load-bearing, and the failure it prevents is subtle: a struct + /// serializes in *declaration* order, so transmitting one sends JSON whose + /// key order differs from the canonical form we signed. Dakota then answers + /// `endorsement validation failed` with no hint as to why. Sending the + /// canonical form makes the wire bytes and the signed bytes identical by + /// construction, so the two can never drift. + pub fn endorse(&self, intent: T) -> Result> { + let canonical = canonicalize(&intent)?; + let signature = self.sign(&intent)?; + let intent = serde_json::from_slice(&canonical) + .context("re-reading the canonical intent")?; + Ok(EndorsedRequest { signatures: vec![signature], intent }) + } +} + +/// RFC 8785 JCS canonical bytes for `value`. +pub fn canonicalize(value: &T) -> Result> { + serde_jcs::to_vec(value).context("canonicalizing intent (RFC 8785 JCS)") +} + +/// Put a decimal amount into the form Dakota signs over. +/// +/// **This is not cosmetic.** Dakota normalizes the amount before rebuilding the +/// intent it verifies against, so a signature over `"1.00"` is checked against +/// `"1"` and fails as `endorsement validation failed` — an error that says +/// nothing about formatting. Verified against the live sandbox: +/// +/// | sent | result | +/// |----------|-------------------------------| +/// | `"1"` | accepted (insufficient balance) | +/// | `"1.00"` | endorsement validation failed | +/// | `"0.50"` | endorsement validation failed | +/// | `"0.01"` | accepted (insufficient balance) | +/// +/// So: strip trailing zeros from the fraction, and drop the point entirely if +/// nothing is left. `"1.00"` → `"1"`, `"0.50"` → `"0.5"`, `"0.01"` → `"0.01"`. +/// +/// Anything that is not a plain decimal is returned untouched — better to let +/// Dakota reject an odd input than to silently rewrite it into a different +/// number. +pub fn normalize_amount(amount: &str) -> String { + let s = amount.trim(); + let Some((whole, frac)) = s.split_once('.') else { + return s.to_string(); + }; + if whole.is_empty() + || !whole.chars().all(|c| c.is_ascii_digit()) + || !frac.chars().all(|c| c.is_ascii_digit()) + { + return s.to_string(); + } + let trimmed = frac.trim_end_matches('0'); + if trimmed.is_empty() { + whole.to_string() + } else { + format!("{whole}.{trimmed}") + } +} + +#[cfg(test)] +mod live_tests; + +#[cfg(test)] +mod tests { + use super::*; + + /// The intent from Dakota's signing guide, field-for-field. + fn doc_intent() -> SendTransactionIntent { + SendTransactionIntent { + wallet_id: "2LfZm5KMnRvLFtRP7nJJug4zJEP".into(), + caip2: "eip155:1".into(), + operation: TransferOperation { + kind: "transfer".into(), + from: "0xYourWalletAddress".into(), + to: "0xDestinationAddress".into(), + amount: "10.5".into(), + asset_id: "USDC".into(), + }, + idempotency_key: "a6f8c8c0-6f0a-4a24-a3a3-9e8a0cf2f7c0".into(), + } + } + + /// Generated once with: + /// openssl ecparam -name prime256v1 -genkey -noout | openssl pkcs8 -topk8 -nocrypt + /// Test-only; the real key lives in Secrets Manager. + const TEST_PEM: &str = "-----BEGIN PRIVATE KEY-----\n\ +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgevZzL1gdAFr88hb2\n\ +OF/2NxApJCzGCEDdfSp6VQO30hyhRANCAAQRWz+jn65BtOMvdyHKcvjBeBSDZH2r\n\ +1RTwjmYSi9R/zpBnuQ4EiMnCqfMPWiZqB4QdbAd0E7oH50VpuZ1P087G\n\ +-----END PRIVATE KEY-----"; + + #[test] + fn jcs_sorts_keys_and_strips_whitespace() { + // JCS orders object members by their UTF-16 code units, so the output + // is independent of declaration order. This is the property that lets + // Dakota re-canonicalize and land on identical bytes. + let a = serde_json::json!({ "b": 1, "a": 2, "c": { "z": 1, "y": 2 } }); + let out = String::from_utf8(canonicalize(&a).unwrap()).unwrap(); + assert_eq!(out, r#"{"a":2,"b":1,"c":{"y":2,"z":1}}"#); + } + + #[test] + fn canonical_form_is_order_independent() { + // Same intent, different struct field order in the source JSON. + let one = serde_json::json!({ + "wallet_id": "w", "caip2": "eip155:1", + "operation": { "kind": "transfer", "amount": "1.50" }, + }); + let two = serde_json::json!({ + "operation": { "amount": "1.50", "kind": "transfer" }, + "caip2": "eip155:1", "wallet_id": "w", + }); + assert_eq!(canonicalize(&one).unwrap(), canonicalize(&two).unwrap()); + } + + #[test] + fn intent_serializes_snake_case_with_string_amounts() { + let bytes = canonicalize(&doc_intent()).unwrap(); + let s = String::from_utf8(bytes).unwrap(); + // Amount must be quoted: a bare 10.5 would canonicalize via JCS number + // rules and diverge from what Dakota reconstructs from its own record. + assert!(s.contains(r#""amount":"10.5""#), "got {s}"); + assert!(s.contains(r#""asset_id":"USDC""#)); + assert!(s.contains(r#""idempotency_key":"a6f8c8c0-6f0a-4a24-a3a3-9e8a0cf2f7c0""#)); + assert!(s.contains(r#""wallet_id":"2LfZm5KMnRvLFtRP7nJJug4zJEP""#)); + // No camelCase leaked in. + assert!(!s.contains("assetId") && !s.contains("walletId")); + // No whitespace. + assert!(!s.contains(' ') || s.contains(r#"" ""#)); + } + + #[test] + fn key_loads_and_public_key_is_der_spki() { + let signer = WalletSigner::from_pem(TEST_PEM).unwrap(); + let b64 = signer.public_key_b64().unwrap(); + let der = base64::engine::general_purpose::STANDARD.decode(&b64).unwrap(); + // 91-byte SPKI for an uncompressed P-256 point, starting with the + // SEQUENCE tag. This is the exact encoding `POST /signers` accepts — + // sandbox echoed it back unchanged. + assert_eq!(der.len(), 91, "unexpected SPKI length"); + assert_eq!(der[0], 0x30, "DER SEQUENCE tag"); + } + + #[test] + fn signature_verifies_against_the_canonical_digest() { + use p256::ecdsa::signature::Verifier; + + let signer = WalletSigner::from_pem(TEST_PEM).unwrap(); + let intent = doc_intent(); + let sig_b64 = signer.sign(&intent).unwrap(); + + let der = base64::engine::general_purpose::STANDARD.decode(&sig_b64).unwrap(); + let sig = DerSignature::from_bytes(&der).unwrap(); + let canonical = canonicalize(&intent).unwrap(); + + // Verifying over the canonical bytes is exactly what Dakota does after + // re-canonicalizing the intent it received. + signer + .key + .verifying_key() + .verify(&canonical, &sig) + .expect("signature must verify over the canonical form"); + } + + #[test] + fn signature_does_not_verify_over_a_tampered_intent() { + use p256::ecdsa::signature::Verifier; + + let signer = WalletSigner::from_pem(TEST_PEM).unwrap(); + let sig_b64 = signer.sign(&doc_intent()).unwrap(); + let der = base64::engine::general_purpose::STANDARD.decode(&sig_b64).unwrap(); + let sig = DerSignature::from_bytes(&der).unwrap(); + + // Someone rewrites the amount in flight. + let mut tampered = doc_intent(); + tampered.operation.amount = "9999.00".into(); + + assert!(signer + .key + .verifying_key() + .verify(&canonicalize(&tampered).unwrap(), &sig) + .is_err()); + } + + #[test] + fn signature_is_der_not_p1363() { + let signer = WalletSigner::from_pem(TEST_PEM).unwrap(); + let der = base64::engine::general_purpose::STANDARD + .decode(signer.sign(&doc_intent()).unwrap()) + .unwrap(); + // A raw P1363 r||s would be exactly 64 bytes with no tag. DER is + // SEQUENCE-wrapped and 70-72 bytes for P-256; browsers get this wrong, + // which is why the docs call it out. + assert_eq!(der[0], 0x30, "expected an ASN.1 SEQUENCE tag"); + assert!(der.len() >= 68 && der.len() <= 72, "got {} bytes", der.len()); + } + + #[test] + fn endorse_wraps_signature_and_intent() { + let signer = WalletSigner::from_pem(TEST_PEM).unwrap(); + let req = signer.endorse(doc_intent()).unwrap(); + assert_eq!(req.signatures.len(), 1); + let body = serde_json::to_value(&req).unwrap(); + assert!(body.get("signatures").unwrap().is_array()); + assert!(body.get("intent").unwrap().is_object()); + } + + #[test] + fn the_transmitted_intent_is_byte_identical_to_what_was_signed() { + // Dakota verifies over the JSON as transmitted. A struct serializes in + // declaration order, so shipping one sends bytes that differ from the + // canonical form we signed, and Dakota answers "endorsement validation + // failed" without saying why. Confirmed against the live sandbox: this + // exact mismatch is what it rejects. + let signer = WalletSigner::from_pem(TEST_PEM).unwrap(); + let req = signer.endorse(doc_intent()).unwrap(); + + let on_the_wire = serde_json::to_vec(&req.intent).unwrap(); + let signed = canonicalize(&doc_intent()).unwrap(); + assert_eq!( + String::from_utf8(on_the_wire).unwrap(), + String::from_utf8(signed).unwrap(), + "the wire form must equal the signed form" + ); + } + + #[test] + fn a_struct_would_not_have_matched() { + // Guards the reasoning above. If serde ever started emitting sorted + // keys for structs this fails, and the canonical round-trip inside + // `endorse` could be simplified away. + assert_ne!( + serde_json::to_vec(&doc_intent()).unwrap(), + canonicalize(&doc_intent()).unwrap(), + "struct order still differs from canonical order" + ); + } + + #[test] + fn amounts_are_normalized_the_way_dakota_expects() { + // Each of these was checked against the live sandbox: the left-hand + // forms are rejected as "endorsement validation failed" when signed + // verbatim, and accepted once normalized. + assert_eq!(normalize_amount("1.00"), "1"); + assert_eq!(normalize_amount("2.00"), "2"); + assert_eq!(normalize_amount("0.50"), "0.5"); + assert_eq!(normalize_amount("0.10"), "0.1"); + // Already normalized — must pass through untouched. + assert_eq!(normalize_amount("0.01"), "0.01"); + assert_eq!(normalize_amount("1"), "1"); + assert_eq!(normalize_amount("1.5"), "1.5"); + assert_eq!(normalize_amount(" 1.20 "), "1.2"); + } + + #[test] + fn normalize_leaves_odd_input_alone() { + // Rewriting something we do not understand risks changing the number. + // Let Dakota reject it instead. + assert_eq!(normalize_amount("abc"), "abc"); + assert_eq!(normalize_amount("1.2.3"), "1.2.3"); + assert_eq!(normalize_amount("-1.00"), "-1.00"); + assert_eq!(normalize_amount(".5"), ".5"); + assert_eq!(normalize_amount(""), ""); + } + + #[test] + fn zero_normalizes_without_losing_the_whole_part() { + assert_eq!(normalize_amount("0.00"), "0"); + assert_eq!(normalize_amount("10.00"), "10"); + } + + #[test] + fn a_bad_pem_is_a_clear_error_not_a_panic() { + assert!(WalletSigner::from_pem("not a pem").is_err()); + assert!(WalletSigner::from_pem("").is_err()); + } +} diff --git a/rust-backend/services/dakota-service/src/webhook.rs b/rust-backend/services/dakota-service/src/webhook.rs new file mode 100644 index 00000000..7b5fa729 --- /dev/null +++ b/rust-backend/services/dakota-service/src/webhook.rs @@ -0,0 +1,556 @@ +//! Dakota webhook receipt. +//! +//! Three things happen here, in order, and the order matters: +//! +//! 1. **Verify.** Dakota signs with Ed25519 (not HMAC) over +//! `{timestamp}.{body}`. An unverified body is discarded without being +//! parsed, let alone stored. +//! 2. **Extract.** We pull out ids, enums, amounts and assets — and nothing +//! else. Dakota event payloads carry `sender_details.sender_account_name` +//! and `sender_account_number`; storing the raw envelope (as the indexer +//! does for chain events) would put bank details in our database. +//! 3. **Record.** Keyed on `X-Dakota-Event-ID`, so a redelivery is a no-op. +//! +//! Dakota does NOT guarantee ordering, so this table is a set of observations, +//! never a sequence. Anything that needs current state re-reads the resource. + +use std::sync::Arc; + +use axum::body::Bytes; +use axum::extract::State; +use axum::http::{HeaderMap, StatusCode}; +use base64::Engine; +use chrono::{DateTime, TimeZone, Utc}; +use ed25519_dalek::{Signature, VerifyingKey}; +use sha2::{Digest, Sha256}; +use tracing::{info, warn}; + +use crate::db::models::{NewLedgerEvent, NewWebhookError}; +use crate::state::AppState; + +/// Reject deliveries older than this. Dakota's own guidance is 5 minutes. +const MAX_SKEW_SECS: i64 = 300; + +/// `POST /webhooks/dakota`. +/// +/// Always answers 2xx once a delivery is verified — including when we cannot +/// make sense of the body. A non-2xx makes Dakota retry for 48 hours, and a +/// payload we failed to parse will fail identically on every retry; the +/// `webhook_errors` row is the durable record instead. +pub async fn receive( + State(state): State>, + headers: HeaderMap, + body: Bytes, +) -> StatusCode { + let event_id = header(&headers, "x-dakota-event-id"); + let digest = sha256_hex(&body); + + if let Err(reason) = verify(&state.webhook_key, &headers, &body) { + // Deliberately terse and 401: an unverified body is not ours to log or + // store, and retrying will not help a forged or misconfigured sender. + warn!(reason, event_id = event_id.as_deref().unwrap_or("-"), "rejected webhook"); + metrics::counter!("dakota_webhooks_total", "outcome" => "unverified").increment(1); + let _ = state.repo.record_webhook_error(&NewWebhookError { + event_id, + reason: reason.to_string(), + body_sha256: digest, + }); + return StatusCode::UNAUTHORIZED; + } + + let Some(event_id) = event_id else { + metrics::counter!("dakota_webhooks_total", "outcome" => "no_event_id").increment(1); + let _ = state.repo.record_webhook_error(&NewWebhookError { + event_id: None, + reason: "missing X-Dakota-Event-ID".into(), + body_sha256: digest, + }); + // Verified but unusable — no id means no idempotency key. Accept it so + // Dakota stops retrying. + return StatusCode::OK; + }; + + let parsed: serde_json::Value = match serde_json::from_slice(&body) { + Ok(v) => v, + Err(e) => { + metrics::counter!("dakota_webhooks_total", "outcome" => "unparseable").increment(1); + let _ = state.repo.record_webhook_error(&NewWebhookError { + event_id: Some(event_id), + reason: format!("body is not json: {e}"), + body_sha256: digest, + }); + return StatusCode::OK; + } + }; + + let mut event = extract(&event_id, &parsed); + // Events name the auto-account, not the customer; attribute it from our + // own accounts table so per-customer totals are not all NULL. + if event.dakota_customer_id.is_none() { + if let Some(acct) = account_ref(&parsed) { + event.dakota_customer_id = state.repo.account_owner(&acct).ok().flatten(); + } + } + match state.repo.record_event(&event) { + Ok(true) => { + metrics::counter!("dakota_webhooks_total", "outcome" => "recorded").increment(1); + info!(event_id, event_type = %event.event_type, "webhook recorded"); + } + Ok(false) => { + metrics::counter!("dakota_webhooks_total", "outcome" => "duplicate").increment(1); + } + Err(e) => { + // The database is down, not the payload's fault — 500 so Dakota + // retries and we do not silently lose the event. + warn!(event_id, error = %e, "recording webhook failed"); + metrics::counter!("dakota_webhooks_total", "outcome" => "db_error").increment(1); + return StatusCode::INTERNAL_SERVER_ERROR; + } + } + StatusCode::OK +} + +/// Ed25519 over `{timestamp}.{raw body}`, plus a freshness check. +fn verify(key: &VerifyingKey, headers: &HeaderMap, body: &[u8]) -> Result<(), &'static str> { + let sig_b64 = header(headers, "x-webhook-signature").ok_or("missing signature header")?; + let ts = header(headers, "x-webhook-timestamp").ok_or("missing timestamp header")?; + + let ts_num: i64 = ts.trim().parse().map_err(|_| "timestamp is not an integer")?; + let age = Utc::now().timestamp() - ts_num; + // Bounded on both sides: a far-future timestamp is as suspect as a stale + // one, and would otherwise stay "fresh" forever. + if age.abs() > MAX_SKEW_SECS { + return Err("timestamp outside the accepted window"); + } + + let sig_bytes = base64::engine::general_purpose::STANDARD + .decode(sig_b64.trim()) + .map_err(|_| "signature is not base64")?; + let sig_arr: [u8; 64] = sig_bytes.try_into().map_err(|_| "signature is not 64 bytes")?; + let signature = Signature::from_bytes(&sig_arr); + + let mut signed = Vec::with_capacity(ts.len() + 1 + body.len()); + signed.extend_from_slice(ts.as_bytes()); + signed.push(b'.'); + signed.extend_from_slice(body); + + key.verify_strict(&signed, &signature) + .map_err(|_| "signature does not verify") +} + +/// Parse the environment's Ed25519 public key (64 hex chars). +pub fn parse_verifying_key(hex_key: &str) -> anyhow::Result { + let raw = hex::decode(hex_key.trim()).map_err(|e| anyhow::anyhow!("webhook key not hex: {e}"))?; + let arr: [u8; 32] = raw + .try_into() + .map_err(|_| anyhow::anyhow!("webhook key must be 32 bytes"))?; + VerifyingKey::from_bytes(&arr).map_err(|e| anyhow::anyhow!("invalid webhook key: {e}")) +} + +/// Reduce a Dakota event to the non-identifying fields we keep. +/// +/// Everything not named here is dropped, which is the point: the input holds +/// bank account numbers and legal names. +fn extract(event_id: &str, v: &serde_json::Value) -> NewLedgerEvent { + let event_type = v + .get("type") + .or_else(|| v.get("event_type")) + .and_then(|t| t.as_str()) + .unwrap_or("unknown") + .to_string(); + + let object = v + .pointer("/data/object") + .or_else(|| v.get("data")) + .unwrap_or(v); + + let receipt = object.get("receipt"); + let (amount_minor, asset) = receipt + .and_then(amount_from_receipt) + .unwrap_or((None, None)); + + NewLedgerEvent { + event_id: event_id.to_string(), + direction: Some(direction_for(&event_type, object).to_string()), + resource_type: resource_type_for(&event_type), + resource_id: str_field(object, "id"), + // Events name the auto-account, not the customer. `resolve_customer` + // fills this in from our own accounts table — without it every + // per-customer total stays empty. + dakota_customer_id: str_field(object, "customer_id") + .or_else(|| str_field(object, "dakota_customer_id")), + amount_minor, + asset, + exchange_rate: receipt.and_then(|r| str_field(r, "exchange_rate")), + fee_minor: receipt.and_then(fee_from_receipt), + status: str_field(object, "status"), + occurred_at: v + .get("created") + .or_else(|| object.get("updated_at")) + .and_then(|t| t.as_i64()) + .and_then(|secs| Utc.timestamp_opt(secs, 0).single()), + event_type, + } +} + +/// Onramps bring value in; offramps send it out. A swap is neither — treating +/// it as both would double-count it in every total. +fn direction_for(event_type: &str, object: &serde_json::Value) -> &'static str { + let kind = str_field(object, "type").unwrap_or_default(); + match kind.as_str() { + "onramp" => "in", + "offramp" => "out", + "swap" => "transfer", + _ if event_type.contains("deposit") => "in", + _ => "transfer", + } +} + +fn resource_type_for(event_type: &str) -> Option { + let head = event_type.split('.').next()?; + Some(head.to_string()) +} + +/// Pull `(minor units, asset)` from a receipt, preferring the output leg — the +/// amount actually delivered is the one worth reporting. +/// +/// Dakota ships receipts in TWO shapes, and which one you get depends on where +/// you read it from: +/// +/// - `GET /auto-transactions` nests them: `{"output":{"amount":"2","asset":"USDC"}}` +/// - `GET /events` and webhook deliveries flatten them: +/// `{"outgoing_amount":"2","output_currency":"USDC"}` +/// +/// Handling only the nested form silently yields NULL amounts for every +/// webhook-sourced row, which is exactly how the ledger ends up full of events +/// that total to nothing. +fn amount_from_receipt(receipt: &serde_json::Value) -> Option<(Option, Option)> { + // Nested form. + if let Some(leg) = receipt.get("output").or_else(|| receipt.get("input")) { + if leg.is_object() { + return Some((minor_units(leg.get("amount")), str_field(leg, "asset"))); + } + } + // Flat form. + let amount = receipt + .get("outgoing_amount") + .or_else(|| receipt.get("converted_amount")) + .or_else(|| receipt.get("initial_amount")); + let asset = str_field(receipt, "output_currency") + .or_else(|| str_field(receipt, "input_currency")); + if amount.is_none() && asset.is_none() { + return None; + } + Some((minor_units(amount), asset)) +} + +/// Dakota's fee field is an object in one shape and a bare decimal string in +/// the other. +fn fee_from_receipt(receipt: &serde_json::Value) -> Option { + let fee = receipt.get("dakota_fee")?; + if fee.is_object() { + return minor_units(fee.get("amount")); + } + minor_units(Some(fee)) +} + +/// Decimal string -> integer minor units (cents / 1e-2). +/// +/// Dakota sends amounts as decimal strings ("2", "1.50"). Storing them as +/// integers keeps the aggregate SUMs exact — floats would drift, and every +/// number here is money. +fn minor_units(v: Option<&serde_json::Value>) -> Option { + let s = v?.as_str()?.trim(); + let (whole, frac) = match s.split_once('.') { + Some((w, f)) => (w, f), + None => (s, ""), + }; + let negative = whole.starts_with('-'); + let whole_digits = whole.trim_start_matches(['-', '+']); + if !whole_digits.chars().all(|c| c.is_ascii_digit()) || !frac.chars().all(|c| c.is_ascii_digit()) + { + return None; + } + let units: i64 = if whole_digits.is_empty() { 0 } else { whole_digits.parse().ok()? }; + // Two decimal places, truncating anything finer. + let mut cents_str = frac.chars().chain(std::iter::repeat('0')).take(2).collect::(); + if cents_str.is_empty() { + cents_str.push('0'); + } + let cents: i64 = cents_str.parse().ok()?; + let total = units.checked_mul(100)?.checked_add(cents)?; + Some(if negative { -total } else { total }) +} + +fn str_field(v: &serde_json::Value, key: &str) -> Option { + v.get(key)?.as_str().map(|s| s.to_string()) +} + +fn header(headers: &HeaderMap, name: &str) -> Option { + headers.get(name)?.to_str().ok().map(|s| s.to_string()) +} + +fn sha256_hex(body: &[u8]) -> String { + let mut h = Sha256::new(); + h.update(body); + hex::encode(h.finalize()) +} + +/// Exposed for the resync path, which builds ledger rows from `GET /events` +/// instead of from a delivery. +pub fn extract_for_resync(event_id: &str, v: &serde_json::Value) -> NewLedgerEvent { + extract(event_id, v) +} + +/// The auto-account an event refers to, if any. +/// +/// Dakota events identify the account, not the customer, so this is the join +/// key callers use against our `accounts` table to attribute a transfer. +pub fn account_ref(v: &serde_json::Value) -> Option { + let object = v.pointer("/data/object").or_else(|| v.get("data")).unwrap_or(v); + str_field(object, "auto_account_id").or_else(|| str_field(object, "account_id")) +} + +#[allow(dead_code)] +fn _assert_datetime_type(_: Option>) {} + +#[cfg(test)] +mod tests { + use super::*; + use ed25519_dalek::{Signer, SigningKey}; + + fn sample_event() -> serde_json::Value { + // Trimmed from a real sandbox `GET /events` row. `sender_details` is + // kept here on purpose: the test asserts we do NOT retain it. + serde_json::json!({ + "api_version": "1.0.0", + "created": 1785699115, + "type": "transaction.auto.updated", + "data": { "object": { + "id": "3HNCPYPeEZCpScXWQbvFJwUeIxB", + "auto_account_id": "3HNCN914HGh2Sr95XpcJBgMPLAT", + "type": "onramp", + "status": "processing", + "receipt": { + "exchange_rate": "1", + "input": { "amount": "2", "asset": "USD" }, + "output": { "amount": "1.50", "asset": "USDC" }, + "dakota_fee": { "amount": "0.25", "asset": "USD" } + }, + "sender_details": { + "sender_account_holder_name": "Sandbox Sender", + "sender_account_number": "9876543210" + } + }} + }) + } + + #[test] + fn extract_keeps_the_ledger_fields() { + let e = extract("evt_1", &sample_event()); + assert_eq!(e.event_type, "transaction.auto.updated"); + assert_eq!(e.resource_type.as_deref(), Some("transaction")); + assert_eq!(e.resource_id.as_deref(), Some("3HNCPYPeEZCpScXWQbvFJwUeIxB")); + assert_eq!(e.status.as_deref(), Some("processing")); + assert_eq!(e.direction.as_deref(), Some("in"), "onramp brings value in"); + // Output leg: 1.50 USDC -> 150 minor units. + assert_eq!(e.amount_minor, Some(150)); + assert_eq!(e.asset.as_deref(), Some("USDC")); + assert_eq!(e.fee_minor, Some(25)); + assert_eq!(e.exchange_rate.as_deref(), Some("1")); + assert!(e.occurred_at.is_some()); + } + + #[test] + fn extract_drops_sender_pii() { + // The guarantee the whole design rests on: nothing identifying can + // reach the database, because the row type has nowhere to put it. + let e = extract("evt_1", &sample_event()); + let serialized = format!("{e:?}"); + assert!(!serialized.contains("Sandbox Sender")); + assert!(!serialized.contains("9876543210")); + } + + #[test] + fn offramp_is_outbound_and_swap_is_neither() { + let mut v = sample_event(); + v["data"]["object"]["type"] = serde_json::json!("offramp"); + assert_eq!(extract("e", &v).direction.as_deref(), Some("out")); + + v["data"]["object"]["type"] = serde_json::json!("swap"); + // Counting a swap as both in and out would double it in every total. + assert_eq!(extract("e", &v).direction.as_deref(), Some("transfer")); + } + + #[test] + fn minor_units_parses_decimal_strings_exactly() { + let f = |s: &str| minor_units(Some(&serde_json::json!(s))); + assert_eq!(f("2"), Some(200)); + assert_eq!(f("1.50"), Some(150)); + assert_eq!(f("0.05"), Some(5)); + assert_eq!(f("0.5"), Some(50), "one decimal place is tenths, not hundredths"); + assert_eq!(f("1.999"), Some(199), "truncates below cents"); + assert_eq!(f("-1.25"), Some(-125)); + assert_eq!(f("1000000"), Some(100_000_000)); + } + + #[test] + fn minor_units_rejects_garbage() { + let f = |s: &str| minor_units(Some(&serde_json::json!(s))); + assert_eq!(f("abc"), None); + assert_eq!(f("1.2.3"), None); + assert_eq!(f(""), Some(0)); + assert_eq!(minor_units(None), None); + assert_eq!(minor_units(Some(&serde_json::json!(2.0))), None, "numbers are not strings here"); + } + + /// The shape `GET /events` and webhook deliveries actually use — flat, + /// not nested. Captured verbatim from the sandbox. + fn flat_receipt_event() -> serde_json::Value { + serde_json::json!({ + "created": 1785699115, + "type": "transaction.auto.updated", + "data": { "object": { + "id": "3HNCPYPeEZCpScXWQbvFJwUeIxB", + "auto_account_id": "3HNCN914HGh2Sr95XpcJBgMPLAT", + "fiat_rail": "ach", + "status": "processing", + "receipt": { + "client_fee": "0", "converted_amount": "2", "dakota_fee": "0.25", + "exchange_rate": "1", "external_fee": "0", "initial_amount": "2", + "input_currency": "USD", "outgoing_amount": "1.75", + "output_currency": "USDC", "subtotal_amount": "2" + }, + "sender_details": { + "sender_account_holder_name": "Sandbox Sender", + "sender_account_number": "9876543210" + } + }} + }) + } + + #[test] + fn flat_receipts_are_parsed_too() { + // Handling only the nested form leaves every webhook-sourced row with a + // NULL amount, and the ledger totals to nothing. + let e = extract("evt_flat", &flat_receipt_event()); + assert_eq!(e.amount_minor, Some(175), "outgoing_amount 1.75 -> 175"); + assert_eq!(e.asset.as_deref(), Some("USDC")); + assert_eq!(e.fee_minor, Some(25), "bare-string dakota_fee 0.25 -> 25"); + assert_eq!(e.exchange_rate.as_deref(), Some("1")); + } + + #[test] + fn flat_receipt_drops_sender_pii() { + let e = extract("evt_flat", &flat_receipt_event()); + let s = format!("{e:?}"); + assert!(!s.contains("Sandbox Sender") && !s.contains("9876543210")); + } + + #[test] + fn account_ref_finds_the_join_key() { + // Events name the account, never the customer — this is what lets a + // transfer be attributed to whoever owns it. + assert_eq!( + account_ref(&flat_receipt_event()).as_deref(), + Some("3HNCN914HGh2Sr95XpcJBgMPLAT") + ); + assert_eq!(account_ref(&serde_json::json!({})), None); + } + + #[test] + fn nested_and_flat_fees_agree() { + let nested = serde_json::json!({ "dakota_fee": { "amount": "0.25", "asset": "USD" } }); + let flat = serde_json::json!({ "dakota_fee": "0.25" }); + assert_eq!(fee_from_receipt(&nested), Some(25)); + assert_eq!(fee_from_receipt(&flat), Some(25)); + } + + #[test] + fn unknown_event_shape_still_yields_a_row() { + // A schema change must not crash the receiver. + let e = extract("evt_x", &serde_json::json!({})); + assert_eq!(e.event_type, "unknown"); + assert!(e.amount_minor.is_none()); + } + + // ------------------------------------------------------- signature + + fn signed_delivery(key: &SigningKey, ts: i64, body: &[u8]) -> HeaderMap { + let mut signed = Vec::new(); + signed.extend_from_slice(ts.to_string().as_bytes()); + signed.push(b'.'); + signed.extend_from_slice(body); + let sig = key.sign(&signed); + + let mut h = HeaderMap::new(); + h.insert( + "x-webhook-signature", + base64::engine::general_purpose::STANDARD + .encode(sig.to_bytes()) + .parse() + .unwrap(), + ); + h.insert("x-webhook-timestamp", ts.to_string().parse().unwrap()); + h + } + + #[test] + fn valid_signature_verifies() { + let sk = SigningKey::from_bytes(&[7u8; 32]); + let st = sk.verifying_key(); + let body = br#"{"type":"x"}"#; + let h = signed_delivery(&sk, Utc::now().timestamp(), body); + assert!(verify(&st, &h, body).is_ok()); + } + + #[test] + fn tampered_body_is_rejected() { + let sk = SigningKey::from_bytes(&[7u8; 32]); + let st = sk.verifying_key(); + let h = signed_delivery(&sk, Utc::now().timestamp(), br#"{"amount":"1.00"}"#); + assert!(verify(&st, &h, br#"{"amount":"9999.00"}"#).is_err()); + } + + #[test] + fn wrong_key_is_rejected() { + let sk = SigningKey::from_bytes(&[7u8; 32]); + let other = SigningKey::from_bytes(&[9u8; 32]); + let st = other.verifying_key(); + let body = br#"{"type":"x"}"#; + let h = signed_delivery(&sk, Utc::now().timestamp(), body); + assert!(verify(&st, &h, body).is_err()); + } + + #[test] + fn stale_and_future_timestamps_are_rejected() { + let sk = SigningKey::from_bytes(&[7u8; 32]); + let st = sk.verifying_key(); + let body = br#"{"type":"x"}"#; + + let stale = signed_delivery(&sk, Utc::now().timestamp() - MAX_SKEW_SECS - 1, body); + assert!(verify(&st, &stale, body).is_err(), "replay window must close"); + + // A far-future stamp would otherwise stay valid indefinitely. + let future = signed_delivery(&sk, Utc::now().timestamp() + MAX_SKEW_SECS + 1, body); + assert!(verify(&st, &future, body).is_err()); + } + + #[test] + fn missing_headers_are_rejected() { + let sk = SigningKey::from_bytes(&[7u8; 32]); + let st = sk.verifying_key(); + assert!(verify(&st, &HeaderMap::new(), b"{}").is_err()); + } + + #[test] + fn sandbox_public_key_parses() { + // The documented sandbox key — a typo here would fail every delivery. + let k = parse_verifying_key( + "7a2f771f3a7ac9ae2a95066df35dc0261d7ce354214736cc232d70b3c66f8a5f", + ); + assert!(k.is_ok()); + assert!(parse_verifying_key("nothex").is_err()); + assert!(parse_verifying_key("aabb").is_err(), "must be 32 bytes"); + } +}