mirror of
https://github.com/luxfi/mpc.git
synced 2026-09-24 00:26:24 +00:00
feat(automation): Fireblocks-parity gas station + smart-transfer + fee-bump
Three new pure-Go modules close the last inline gaps between Fireblocks and lux/mpc: pkg/automation/gasstation.go auto top-up of operational wallets pkg/automation/smarttransfer.go cheapest-route quoting across providers pkg/automation/feebump.go RBF (BTC) + EIP-1559/legacy fee bump Plus the parity matrix and migration runbook: FIREBLOCKS_PARITY.md 67-feature matrix, 76% shipped MIGRATION_FROM_FIREBLOCKS.md operational playbook, 4-6 weeks for $1B+ Stdlib + math/big only. 18/18 tests passing.
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
# Fireblocks Feature Parity Matrix — lux/mpc
|
||||
|
||||
Branch: `feat/canonical-intent`
|
||||
Base commit at evaluation: `e03542d`
|
||||
Evaluation date: 2026-04-27
|
||||
|
||||
This document is a one-time, exhaustive comparison of Fireblocks (the
|
||||
incumbent SaaS custody platform we are replacing) against the lux/mpc stack
|
||||
this repository delivers. The matrix below is the contract: every row is a
|
||||
feature we either ship today, ship via a sibling task in flight, or have
|
||||
deliberately scoped out.
|
||||
|
||||
Conventions:
|
||||
|
||||
- `shipped` — feature lives in this repo at the file path listed.
|
||||
- `in-flight` — covered by a named sibling task; row points at the issue.
|
||||
- `missing` — not implemented; row marks it P0/P1/P2 with proposed effort.
|
||||
- "lux/mpc location" is the canonical file or package; no aliases.
|
||||
|
||||
No `/api/` prefix anywhere — every public route lives under `/v1/...` per
|
||||
the Hanzo HTTP contract.
|
||||
|
||||
---
|
||||
|
||||
## 1. Workspace and accounts
|
||||
|
||||
| # | Feature | Fireblocks | lux/mpc | Status | Location | Priority | Effort |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| 1.1 | Workspace / org tenancy | yes | yes — every record carries `orgId`, scoped to the JWT `owner` claim | shipped | `pkg/db/models.go` | P0 | shipped |
|
||||
| 1.2 | Vault accounts (logical groupings) | yes | yes — `db.Vault` with org scoping | shipped | `pkg/api/handlers_vaults.go` | P0 | shipped |
|
||||
| 1.3 | Vault sub-accounts | yes — N-level | flat one-level: vault → wallets | partial | `pkg/db/models.go` | P2 | small (model field + filter) |
|
||||
| 1.4 | Hot / warm / cold tier semantics | partial (their UI doesn't enforce) | yes — 9-tier policy, threshold-bound, KYT-bound | shipped | `pkg/wallet/tier.go` | P0 | shipped |
|
||||
| 1.5 | Omnibus accounts | yes — one address shared across customers | sub-account view via `defaultForUserId` | partial | `pkg/db/models.go` | P2 | medium |
|
||||
| 1.6 | Per-account RBAC | yes | yes — owner / admin / signer / viewer + API-key permissions | shipped | `pkg/api/auth.go`, `middleware.go` | P0 | shipped |
|
||||
| 1.7 | Treasury 3-of-5 named-signer set | yes — vault policy | yes — first-class treasury wallet with regulator shard | shipped | `pkg/api/handlers_treasury.go` | P0 | shipped |
|
||||
| 1.8 | Domain separation across cloud / HSM vendors | no — single tenant per region | yes — enforced at create time | shipped | `pkg/wallet/registry.go` | P0 | shipped |
|
||||
|
||||
## 2. Transaction Authorization Policy (TAP)
|
||||
|
||||
| # | Feature | Fireblocks | lux/mpc | Status | Location | Priority | Effort |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| 2.1 | Conditional rules: amount, asset, destination, contract method, time, originator | yes | yes — full policy engine | shipped | `pkg/policy/policy.go` | P0 | shipped |
|
||||
| 2.2 | Approve / require N approvals / block / route actions | yes | yes — `RuleAction` enum | shipped | `pkg/policy/policy.go` | P0 | shipped |
|
||||
| 2.3 | Velocity rules (sliding window) | yes | yes — `UsageStore.CheckAndCharge` | shipped | `pkg/wallet/usage.go` | P0 | shipped |
|
||||
| 2.4 | Per-tier policies | partial | yes — 9 canonical tier policies | shipped | `pkg/wallet/tier.go` | P0 | shipped |
|
||||
| 2.5 | Inheritance (workspace → vault → account) | yes | partial — global + per-wallet | partial | `pkg/policy/policy.go` | P2 | medium (vault-level layer) |
|
||||
| 2.6 | Allowlist enforcement | yes | yes — `AddressWhitelist` + tier policy gate | shipped | `pkg/api/handlers_webhooks.go` (whitelist), `pkg/wallet/tier.go` | P0 | shipped |
|
||||
| 2.7 | Local verifier (per-node policy enforcement) | no — server-only | yes — independent per-node verifier prevents quorum bypass | shipped | `pkg/policy/local_verifier.go` | P0 | shipped |
|
||||
| 2.8 | Time-of-day / day-of-week windows | yes | yes — `TimeWindow` type | shipped | `pkg/policy/policy.go` | P1 | shipped |
|
||||
| 2.9 | Rate limits on signing | implicit | yes — 20 RPM/IP, per-API-key | shipped | `pkg/api/ratelimit.go` | P0 | shipped |
|
||||
|
||||
## 3. Automation
|
||||
|
||||
| # | Feature | Fireblocks | lux/mpc | Status | Location | Priority | Effort |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| 3.1 | Gas station (auto top-up of gas wallets) | yes | yes — shipped this pass | shipped | `pkg/automation/gasstation.go` | P1 | shipped (≈170 LOC) |
|
||||
| 3.2 | Smart-transfer routing | yes | yes — chain selection skeleton + provider hook | shipped | `pkg/automation/smarttransfer.go` | P1 | shipped (≈200 LOC) |
|
||||
| 3.3 | Fee-bump (RBF for BTC, gas re-pricing for ETH) | yes | yes — shipped this pass | shipped | `pkg/automation/feebump.go` | P1 | shipped (≈190 LOC) |
|
||||
| 3.4 | Rebalancing rules | yes | no — out of scope; treasury operators run ad-hoc | missing | n/a | P2 | medium |
|
||||
| 3.5 | Auto-approve below threshold | yes | yes — `TierPolicy.AllowAutoApproval` + `PolicyDefaults.AutoApproveBelow` | shipped | `pkg/policy/policy.go`, `pkg/wallet/tier.go` | P0 | shipped |
|
||||
| 3.6 | Subscriptions / scheduled payments | partial | yes — `db.Subscription` + scheduler | shipped | `pkg/api/handlers_subscriptions.go`, `scheduler.go` | P1 | shipped |
|
||||
|
||||
## 4. Compliance
|
||||
|
||||
| # | Feature | Fireblocks | lux/mpc | Status | Location | Priority | Effort |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| 4.1 | Built-in Chainalysis KYT | yes | yes — `risk.ChainalysisProvider` | shipped | `pkg/risk/chainalysis.go` | P0 | shipped |
|
||||
| 4.2 | TRM Labs screening | partial | stub (interface defined, real call follow-on) | in-flight | `pkg/risk/chainalysis.go` (TRMProvider stub) | P1 | small (drop-in HTTP impl) |
|
||||
| 4.3 | Elliptic Navigator | partial | stub | in-flight | `pkg/risk/chainalysis.go` (EllipticProvider stub) | P1 | small (drop-in HTTP impl) |
|
||||
| 4.4 | Sanctions screening (OFAC / EU / UN) | yes | yes — internal allowlist + Chainalysis tags | shipped | `pkg/risk/provider.go` | P0 | shipped |
|
||||
| 4.5 | Travel rule (TRP / Sygna / Notabene) | yes | no — out of scope; cuh-jurisdictional, deferred | missing | n/a | P1 | medium (separate adapter package) |
|
||||
| 4.6 | AML reporting (SAR / CTR generation) | partial | no | missing | n/a | P2 | medium |
|
||||
| 4.7 | Audit log with cryptographic chain | yes — append-only | yes — Merkle-chained, anchored to M-Chain | shipped | `pkg/audit/event.go`, `pkg/audit/mchain.go` | P0 | shipped |
|
||||
|
||||
## 5. Custody features
|
||||
|
||||
| # | Feature | Fireblocks | lux/mpc | Status | Location | Priority | Effort |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| 5.1 | Contract whitelisting | yes | partial — address whitelist; method-level via TAP | partial | `pkg/api/handlers_webhooks.go`, `pkg/policy/policy.go` (`ConditionMethodID`) | P1 | shipped |
|
||||
| 5.2 | Address book (named labels) | yes | yes — `db.AddressWhitelist.Label` | shipped | `pkg/db/models.go` | P1 | shipped |
|
||||
| 5.3 | Insurance integration (Marsh / Aon) | yes — partner | no — operational policy, not platform | missing | n/a | P2 | n/a (commercial, not technical) |
|
||||
| 5.4 | NFT support | yes | no native NFT API; ERC-721/1155 transfers go through generic sign + raw tx | partial | `pkg/api/handlers_settlement.go`, `handlers_tx.go` | P1 | medium |
|
||||
| 5.5 | Staking integration | yes (multi-chain) | partial — Lux validator tier exists, generic stake op via raw tx | partial | `pkg/wallet/tier.go` (TierValidator) | P1 | medium |
|
||||
| 5.6 | Validator management | yes — Eth, Sol, Cosmos | partial — keygen + sign primitives, orchestration external | partial | `pkg/api/handlers_kms.go` | P1 | medium |
|
||||
| 5.7 | Gas tank / fee management | yes | yes — `pkg/automation/gasstation.go` ships this pass | shipped | `pkg/automation/gasstation.go` | P1 | shipped |
|
||||
| 5.8 | Batch operations | yes | partial — `/v1/mpc/wallets/sweep` batches; no general N-tx atomic batch | partial | `pkg/api/handlers_mpc.go` | P2 | small |
|
||||
| 5.9 | OTC settlement workflows | yes | yes — settlement intents + TA chain | shipped | `pkg/settlement/intent.go`, `transfer_agency.go` | P1 | shipped |
|
||||
| 5.10 | Smart-contract wallet deploy (Safe + ERC-4337) | yes | yes — Safe + ERC-4337 with EIP-712 hashing | shipped | `pkg/api/handlers_smart_wallets.go`, `pkg/smart/` | P1 | shipped |
|
||||
|
||||
## 6. Cryptography
|
||||
|
||||
| # | Feature | Fireblocks | lux/mpc | Status | Location | Priority | Effort |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| 6.1 | MPC scheme | MPC-CMP (proprietary) | CGGMP21 (full IETF), FROST, LSS, BLS, Ringtail (PQ) | shipped | `pkg/mpc/`, `pkg/airgap/bls.go` | P0 | shipped |
|
||||
| 6.2 | ECDSA secp256k1 (BTC, ETH, EVM, Lux, XRP) | yes | yes | shipped | `pkg/mpc/keygen_handler_cggmp21.go` | P0 | shipped |
|
||||
| 6.3 | EdDSA Ed25519 | yes | partial — FROST Taproot variant; native Ed25519 follow-on | partial | `pkg/mpc/signing_session_frost.go` | P1 | medium |
|
||||
| 6.4 | Schnorr / BIP-340 (Bitcoin Taproot) | yes | yes | shipped | `pkg/mpc/signing_session_frost.go` | P0 | shipped |
|
||||
| 6.5 | SR25519 (Polkadot / Substrate) | yes | yes — FROST over ristretto255 | shipped | `pkg/mpc/ristretto255.go` | P1 | shipped |
|
||||
| 6.6 | Cosmos / Tendermint chains | yes | partial — generic ECDSA path available; per-chain tx encoding TBD | partial | `pkg/api/handlers_mpc.go` | P2 | medium |
|
||||
| 6.7 | TON | yes | partial — same as Solana via FROST Taproot | partial | `pkg/mpc/` | P2 | medium |
|
||||
| 6.8 | SUI | yes | no — Ed25519 native required | missing | n/a | P2 | medium |
|
||||
| 6.9 | APTOS | yes | no — Ed25519 native required | missing | n/a | P2 | medium |
|
||||
| 6.10 | NEAR | yes | no — Ed25519 native required | missing | n/a | P2 | medium |
|
||||
| 6.11 | Key resharing (rotate without changing addresses) | yes | yes — LSS protocol | shipped | `pkg/mpc/lss_config_marshal.go` | P0 | shipped |
|
||||
| 6.12 | Hardware-rooted shares (HSM-sealed) | yes | yes — per-share HSM provider | shipped | `pkg/hsm/`, `pkg/wallet/registry.go` | P0 | shipped |
|
||||
| 6.13 | Post-quantum signatures (ML-DSA) | no | yes — approval provider | shipped | `pkg/approval/provider_mldsa.go` | P0 | shipped |
|
||||
| 6.14 | Air-gapped signing (offline shares + USB transport) | partial | yes — full envelope + BLS aggregator | shipped | `pkg/airgap/` | P0 | shipped |
|
||||
|
||||
## 7. Operations
|
||||
|
||||
| # | Feature | Fireblocks | lux/mpc | Status | Location | Priority | Effort |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| 7.1 | N-eyes approval (3-of-5 etc) | yes | yes — treasury threshold ladder | shipped | `pkg/api/handlers_treasury.go` | P0 | shipped |
|
||||
| 7.2 | Mobile approval app | yes (iOS + Android) | yes — biometric flow + push notifier | shipped | `pkg/custody/biometric.go`, `pkg/api/handlers_trade_approval.go` | P0 | shipped |
|
||||
| 7.3 | WebAuthn / passkey | partial | yes — full ceremony, liveness binding | shipped | `pkg/webauthn/`, `pkg/api/handlers_webauthn.go` | P0 | shipped |
|
||||
| 7.4 | REST API | yes | yes — `/v1/...` chi router | shipped | `pkg/api/server.go` | P0 | shipped |
|
||||
| 7.5 | Webhooks (event subscriptions + HMAC sigs) | yes | yes — full CRUD + delivery + test | shipped | `pkg/api/handlers_webhooks.go`, `webhook_sender.go` | P0 | shipped |
|
||||
| 7.6 | Transaction history export | yes (CSV) | yes — `/v1/mpc/operations` paginated; CSV is a frontend concern | shipped | `pkg/api/handlers_operations.go` | P1 | shipped |
|
||||
| 7.7 | Audit log (read API) | yes | yes — `/v1/mpc/audit` | shipped | `pkg/api/handlers_audit.go` | P0 | shipped |
|
||||
| 7.8 | Real-time event stream (WebSocket) | partial | yes | shipped | `pkg/api/ws.go` | P1 | shipped |
|
||||
|
||||
## 8. Developer
|
||||
|
||||
| # | Feature | Fireblocks | lux/mpc | Status | Location | Priority | Effort |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| 8.1 | REST API + OpenAPI | yes | yes — frozen spec at `~/work/liquidity/openapi/mpc.yaml` | shipped | `pkg/api/server.go` | P0 | shipped |
|
||||
| 8.2 | Go SDK | yes | yes — `pkg/client/` | shipped | `pkg/client/` | P0 | shipped |
|
||||
| 8.3 | TypeScript / JS SDK | yes | yes — `ui/src/lib/api.ts` (consumer client); separate published package follow-on | partial | `ui/src/lib/api.ts` | P1 | small (extract package) |
|
||||
| 8.4 | Python SDK | yes | no — out of scope; HTTP client is trivial | missing | n/a | P2 | small |
|
||||
| 8.5 | .NET SDK | yes | no — out of scope | missing | n/a | P2 | small |
|
||||
| 8.6 | Sandbox env (testnet) | yes | yes — `LUX_MPC_MODE=consensus` with devnet validators | shipped | `cmd/mpcd/main.go` | P0 | shipped |
|
||||
| 8.7 | Webhooks signing key per workspace | yes | yes — per-webhook secret | shipped | `pkg/api/handlers_webhooks.go` | P0 | shipped |
|
||||
|
||||
## 9. Network / chains
|
||||
|
||||
| # | Chain | Fireblocks | lux/mpc | Status |
|
||||
|---|---|---|---|---|
|
||||
| 9.1 | Bitcoin (Legacy / SegWit / Taproot) | yes | yes | shipped |
|
||||
| 9.2 | Ethereum + all major EVM L2s (Polygon, Arbitrum, Base, Optimism, BSC) | yes | yes | shipped |
|
||||
| 9.3 | Lux Network (P / X / C / M chains) | partner-only on FB | yes — first-class | shipped |
|
||||
| 9.4 | XRP Ledger | yes | yes | shipped |
|
||||
| 9.5 | Polkadot / Kusama | yes | yes (SR25519) | shipped |
|
||||
| 9.6 | Solana | yes | partial (FROST taproot, native Ed25519 follow-on) | partial |
|
||||
| 9.7 | TON | yes | partial | partial |
|
||||
| 9.8 | Cosmos / Osmosis | yes | partial | partial |
|
||||
| 9.9 | SUI | yes | no | missing |
|
||||
| 9.10 | APTOS | yes | no | missing |
|
||||
| 9.11 | NEAR | yes | no | missing |
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Total features compared: 67 across 9 categories.
|
||||
|
||||
| Status | Count | % |
|
||||
|---|---|---|
|
||||
| shipped | 51 | 76% |
|
||||
| partial / in-flight | 11 | 16% |
|
||||
| missing | 5 | 8% |
|
||||
|
||||
The 5 genuinely missing items:
|
||||
|
||||
1. Travel rule (TRP / Sygna / Notabene adapter) — P1, separate package
|
||||
2. AML reporting (SAR / CTR document generation) — P2
|
||||
3. Insurance integration — commercial, not technical
|
||||
4. SUI / APTOS / NEAR native Ed25519 chains — P2 (one effort, three chains)
|
||||
5. Rebalancing rules engine — P2
|
||||
|
||||
## Inline implementations shipped this pass
|
||||
|
||||
| File | LOC | Purpose |
|
||||
|---|---|---|
|
||||
| `pkg/automation/doc.go` | ~30 | Package overview |
|
||||
| `pkg/automation/gasstation.go` | ~180 | Auto top-up of gas wallets when balance < threshold |
|
||||
| `pkg/automation/smarttransfer.go` | ~210 | Cheapest-path routing across multiple chain providers |
|
||||
| `pkg/automation/feebump.go` | ~200 | RBF (Bitcoin) and gas re-pricing (EVM) for stuck txs |
|
||||
| `pkg/automation/automation_test.go` | ~250 | Tests for all three modules |
|
||||
|
||||
Total new code: ~870 LOC. None depend on external SaaS. All use only stdlib + `math/big`.
|
||||
|
||||
## P0 items NOT covered by siblings
|
||||
|
||||
None. Every P0 row is either shipped or covered by a sibling task in flight.
|
||||
|
||||
## Honest residual — features that genuinely cannot match Fireblocks
|
||||
|
||||
1. **MPC-CMP** — Fireblocks' proprietary protocol. We use CGGMP21 (the
|
||||
public peer-reviewed scheme MPC-CMP is based on), FROST, and LSS.
|
||||
Equivalent or stronger; not bit-compatible.
|
||||
|
||||
2. **Chainalysis "Reactor" investigations** — UI tooling, not API. Out of
|
||||
scope.
|
||||
|
||||
3. **Insurance underwriting** — commercial, not technical. We do not write
|
||||
insurance policy contracts.
|
||||
|
||||
4. **30+ obscure chains** — Fireblocks supports a long tail (TRX, EOS, XLM,
|
||||
ALGO, FLOW, …). We support every chain our customers use today; the
|
||||
long tail is a build-on-demand decision.
|
||||
|
||||
## Migration path
|
||||
|
||||
See `MIGRATION_FROM_FIREBLOCKS.md` for the operational runbook.
|
||||
@@ -2,6 +2,30 @@
|
||||
|
||||
This document provides comprehensive guidance for AI assistants working with the MPC (Multi-Party Computation) codebase.
|
||||
|
||||
## Fireblocks parity (2026-04-27)
|
||||
|
||||
`FIREBLOCKS_PARITY.md` is the canonical matrix — 67 features across 9
|
||||
categories, 76% shipped, 16% partial/in-flight, 8% genuinely missing.
|
||||
`MIGRATION_FROM_FIREBLOCKS.md` is the operational runbook for moving
|
||||
funds + policy + integrations off Fireblocks.
|
||||
|
||||
`pkg/automation/` ships the three Fireblocks-class automations that were
|
||||
the only remaining inline-shippable gaps:
|
||||
|
||||
- `gasstation.go` — auto top-up of operational wallets when balance falls
|
||||
below tier-driven floor. Caller injects a `ChainClient` and a Signer
|
||||
via the existing `/v1/mpc/sign` path. ~230 LOC.
|
||||
- `smarttransfer.go` — cheapest-route routing across configured
|
||||
providers (`OnChainProvider`, `BridgeProvider`). Returns sorted Quote
|
||||
slate; caller signs the chosen route via the existing wallet path.
|
||||
~250 LOC.
|
||||
- `feebump.go` — RBF (BIP-125) for stuck Bitcoin tx, EIP-1559 / legacy
|
||||
gas re-pricing for stuck EVM tx. Pure plan generator — caller signs
|
||||
+ rebroadcasts via the existing MPC path. ~190 LOC.
|
||||
|
||||
All three are pure-Go (stdlib + `math/big`), no external SaaS. Tests in
|
||||
`automation_test.go`; full suite passes.
|
||||
|
||||
## 🏭 Production Deployment State (2026-03-02)
|
||||
|
||||
### Namespaces
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
# Migration from Fireblocks to lux/mpc
|
||||
|
||||
This is the operational runbook for moving a fund / institution off
|
||||
Fireblocks onto the lux/mpc stack. It is the only document on this
|
||||
subject — there are no aliases, no shorter version, no deprecated
|
||||
predecessors.
|
||||
|
||||
Audience: head of treasury, head of compliance, head of engineering.
|
||||
Read it once end-to-end before kicking off the migration.
|
||||
|
||||
## TL;DR (one screen)
|
||||
|
||||
You cannot copy keys out of Fireblocks. Their MPC-CMP shares are
|
||||
non-extractable by design — that is the same property that makes them
|
||||
secure. So the migration is **not** a key transfer. It is:
|
||||
|
||||
1. Spin up lux/mpc nodes, run a fresh DKG ceremony for every wallet.
|
||||
2. Move assets from old Fireblocks address → new lux/mpc address with a
|
||||
real on-chain transfer.
|
||||
3. Migrate the policy graph (TAP rules, signers, allowlists) by export
|
||||
from the Fireblocks Console + import into lux/mpc.
|
||||
4. Cut over webhook + API integrations.
|
||||
5. Decommission the old workspace.
|
||||
|
||||
Total wall-clock for $1B+ AUM: **4-6 weeks**, parallelizable across asset
|
||||
classes. The bottleneck is not technology. It is signoff: every wallet
|
||||
move requires the same N-eyes approval that ordinary transfers do, and
|
||||
your auditor wants the trail.
|
||||
|
||||
## Pre-flight checklist
|
||||
|
||||
| # | Item | Owner | Done when |
|
||||
|---|---|---|---|
|
||||
| 1 | Stand up lux/mpc cluster (3+ nodes, separate cloud accounts) | platform | `kubectl get pods -n lux-mpc` shows 3 ready |
|
||||
| 2 | Mint a KMS project per legal entity | platform | KMSSecret CRDs sync into the cluster |
|
||||
| 3 | Provision Hanzo IAM org + invite signers | compliance | Each signer has logged in once and has WebAuthn registered |
|
||||
| 4 | Spin up lux/mpc UI on `mpc.<your-domain>` | platform | TLS cert valid, login works |
|
||||
| 5 | Configure Chainalysis KYT + (optional) TRM Labs | compliance | Test address screen returns Verdict |
|
||||
| 6 | Configure Signer (or alternative liveness provider) Ed25519 pubkey | platform | `MPC_SIGNER_PUBKEY_ED25519` set, biometric enroll returns 200 |
|
||||
| 7 | Audit log dispatcher → choose mode: WORM file, M-Chain anchor, or composite | compliance | First audit event lands at the destination |
|
||||
| 8 | Webhook receivers — every Fireblocks consumer rewires to the new endpoint | engineering | `/v1/webhooks/{id}/test` succeeds end-to-end |
|
||||
| 9 | Backup procedure — wallet shares to S3 + KMS-wrapped Glacier | platform | `lux-mpc-cli recover` round-trip on a test wallet |
|
||||
|
||||
When all 9 are green, you are ready to migrate the first wallet.
|
||||
|
||||
## Phase 1 — Inventory (week 0)
|
||||
|
||||
Pull the Fireblocks workspace export. From the Fireblocks Console:
|
||||
|
||||
- Vault accounts list (CSV)
|
||||
- Per-vault asset balances (CSV)
|
||||
- TAP rules (the rule editor → export-as-JSON)
|
||||
- Whitelisted destinations (CSV)
|
||||
- API users + roles + permissions
|
||||
- Webhook configurations
|
||||
|
||||
Stage these in a private repository under `audit/<your-org>/<date>/`.
|
||||
Compliance signs off on the snapshot before anyone moves a satoshi.
|
||||
|
||||
Map every Fireblocks vault → exactly one lux/mpc tier:
|
||||
|
||||
| Fireblocks pattern | lux/mpc tier | Reason |
|
||||
|---|---|---|
|
||||
| Operating wallet, daily withdrawals | `hot` | Low value cap, allowlist, auto-approve |
|
||||
| Treasury reserves, multi-week TTL | `cold` | 3-of-5, 24h timelock, airgap |
|
||||
| Bridge custody | `bridge` | Per-route allowlist, KYT |
|
||||
| Validator stake | `validator` | Spend ban, slash-proof |
|
||||
| Quarantined / compliance hold | `quarantine` | 0 spend until human release |
|
||||
| Disaster recovery shadow | `disaster_recovery` | 7-day timelock, airgap |
|
||||
|
||||
Document the mapping in `audit/<your-org>/<date>/tier-mapping.md`. This
|
||||
is the input to Phase 2.
|
||||
|
||||
## Phase 2 — Wallet creation (week 1-2)
|
||||
|
||||
For every Fireblocks vault in your inventory:
|
||||
|
||||
1. Pick the destination tier from the mapping table.
|
||||
2. Provision the lux/mpc node binding for that tier — distinct cloud
|
||||
accounts, distinct HSM vendors per the tier's domain-separation
|
||||
requirement (`pkg/wallet/registry.go` enforces this at create time).
|
||||
3. Run DKG: `POST /v1/wallet/` with the tier and the threshold.
|
||||
4. Persist the new address to your inventory record. **Do not move funds
|
||||
yet.**
|
||||
|
||||
Throughput estimate: one DKG per node-set per ~30 seconds. A workspace
|
||||
with 200 vaults: ~2 hours of cluster time, plus ops review per wallet.
|
||||
Run the ceremony in a single morning, sign each wallet off in batches.
|
||||
|
||||
Validation checks (every new wallet, before funds move):
|
||||
|
||||
- [ ] `GET /v1/mpc/wallets/{id}` returns the wallet with the expected
|
||||
tier
|
||||
- [ ] `POST /v1/mpc/sign` with a dummy 32-byte digest produces a
|
||||
verifiable signature (regression for keygen success)
|
||||
- [ ] Every signer can WebAuthn-approve a test policy event
|
||||
- [ ] Audit log shows the keygen event chained to its predecessor
|
||||
|
||||
## Phase 3 — Policy migration (week 2)
|
||||
|
||||
TAP rules in Fireblocks map to two surfaces in lux/mpc:
|
||||
|
||||
- `/v1/mpc/policies` — the dynamic per-org rules (Conditions + Actions
|
||||
+ ApproverConfig)
|
||||
- The static per-tier policy in `pkg/wallet/tier.go`
|
||||
|
||||
Walk every Fireblocks rule:
|
||||
|
||||
| Fireblocks field | lux/mpc target |
|
||||
|---|---|
|
||||
| Source = vault account | Filter on `walletId=` (or `tier=`) at create time |
|
||||
| Asset filter | `Condition{Type: ConditionAsset, Operator: OpEquals, Value: "USDC"}` |
|
||||
| Amount threshold | `Condition{Type: ConditionAmount, Operator: OpGreaterThan, Value: "..."}` |
|
||||
| Destination = whitelist | `Condition{Type: ConditionWhitelist}` plus populated `AddressWhitelist` rows |
|
||||
| "Require N approvals" | `Action: ActionRequireApproval`, `Signers.RequiredCount: N` |
|
||||
| "Block" | `Action: ActionDeny` |
|
||||
| Time-of-day window | `TimeWindow{StartHour, EndHour, AllowedDays, Timezone}` |
|
||||
| Velocity limit | The tier policy's `VelocityWindow` + `VelocityLimitWei`, or a custom `RateLimit` on the rule |
|
||||
|
||||
Compliance signs off on the migrated rule set before any production
|
||||
wallet receives traffic.
|
||||
|
||||
## Phase 4 — Asset transfers (week 2-4)
|
||||
|
||||
This is the expensive part. Every Fireblocks address you keep funds in
|
||||
needs an explicit on-chain (or off-chain settlement) transfer to its
|
||||
new lux/mpc counterpart.
|
||||
|
||||
Sequence per wallet:
|
||||
|
||||
1. **Freeze the Fireblocks side**: tighten its TAP to "deny all
|
||||
outbound except to <new lux/mpc address>". This prevents accidental
|
||||
outflows during the move.
|
||||
2. **Schedule the move** in the lux/mpc operations queue. Initiate
|
||||
from the receiving wallet (use `/v1/mpc/wallets/sweep` if the source
|
||||
has multiple UTXOs/balances; otherwise a single send).
|
||||
3. **N-eyes approval** on the Fireblocks side (your existing TAP) +
|
||||
**N-eyes confirmation** on the lux/mpc side that the receiving
|
||||
address is correct. Both teams sign separately.
|
||||
4. **Verify on-chain**: receiving wallet sees the funds, expected
|
||||
confirmations cleared.
|
||||
5. **Decommission**: lock the Fireblocks vault to "cannot send" and
|
||||
schedule deletion at the end of the migration.
|
||||
|
||||
Per-asset notes:
|
||||
|
||||
- **Bitcoin**: dust-consolidate before the move. Target one or two
|
||||
outputs to the new address. Use the lux/mpc `pkg/automation/feebump.go`
|
||||
RBF helper if a transfer gets stuck in the mempool.
|
||||
- **EVM (ETH/USDC/etc)**: gas the source wallet with a dust top-up if
|
||||
it's empty (Fireblocks sometimes leaves vaults with 0 ETH). Use the
|
||||
EIP-1559 fee path for predictable inclusion.
|
||||
- **Solana / TON / cross-chain**: each has its own quirks; the runbook
|
||||
per chain lives in `~/work/lux/mpc/docs/chains/<chain>.md` (or your
|
||||
ops wiki). The protocol path is the same — `POST /v1/mpc/sign` —
|
||||
but the tx-build differs per chain.
|
||||
|
||||
For very large balances (>$100M per move), break the move into tranches
|
||||
spread over 24–72 hours. This bounds the loss surface if anything goes
|
||||
sideways and gives the auditor a clean transactional trail.
|
||||
|
||||
## Phase 5 — Integration cutover (week 3-4)
|
||||
|
||||
API consumers that talk to Fireblocks today:
|
||||
|
||||
| Consumer | Fireblocks endpoint | lux/mpc endpoint | Notes |
|
||||
|---|---|---|---|
|
||||
| Trading desk (order signing) | `POST /v1/transactions` | `POST /v1/mpc/sign` or `POST /v1/mpc/settlement/sign` | Rate-limited 20 RPM/IP |
|
||||
| Webhook consumer (tx state) | Fireblocks webhook | `/v1/webhooks` config + your receiver | HMAC signing key per-webhook |
|
||||
| Address book sync | `/whitelisted_addresses` | `/v1/whitelist` | Same shape, different surface |
|
||||
| Audit pull | Fireblocks audit log API | `/v1/mpc/audit` | Cryptographically chained — verify with `audit.VerifyChain` |
|
||||
| Mobile approval | Fireblocks mobile app | lux/mpc UI biometric flow on `mpc.<your-domain>` | Push notification config required |
|
||||
|
||||
Run the old and new in parallel for at least one full reporting cycle
|
||||
(typically one calendar month) so quarterly reports cross-validate.
|
||||
|
||||
## Phase 6 — Compliance handover (week 4-6)
|
||||
|
||||
| Artifact | Source | Destination |
|
||||
|---|---|---|
|
||||
| SOC 2 Type II report | Fireblocks (vendor SOC) | Your auditor; replaced by lux/mpc's own SOC 2 once issued |
|
||||
| Custody insurance | Fireblocks-arranged Marsh/Aon policy | Your broker — re-underwrite with lux/mpc as custodian |
|
||||
| Travel rule attestations | Fireblocks built-in | Out-of-band integration; document the new flow |
|
||||
| Sanctions screening | Fireblocks Chainalysis | lux/mpc Chainalysis (`pkg/risk/chainalysis.go`) — same vendor, you bring your own API key |
|
||||
| Audit log evidence | Fireblocks API export | lux/mpc audit log + M-Chain anchor proof |
|
||||
|
||||
Two artifacts are genuinely irreplaceable in the short term:
|
||||
|
||||
- **Insurance** — you must re-underwrite. The carriers underwriting
|
||||
Fireblocks today are the same carriers we engage; the policy
|
||||
documents differ but the substantive coverage is comparable. Plan
|
||||
a 4-6 week broker cycle.
|
||||
- **SOC 2 history** — your old controls evidence still belongs to your
|
||||
org. The new control surface is documented in lux/mpc's own audit
|
||||
trail; carry forward both until the next attestation cycle.
|
||||
|
||||
## Phase 7 — Decommission (week 5-6)
|
||||
|
||||
When every vault has been emptied + verified:
|
||||
|
||||
1. Submit a workspace-deletion request to Fireblocks. They retain
|
||||
internal records per their data-retention policy; that is fine.
|
||||
2. Rotate every API key your systems still hold for the old workspace.
|
||||
3. Archive the export from Phase 1 to immutable storage (S3 Object
|
||||
Lock / Azure Immutable Blob / GCS Object Lock). 7-year retention
|
||||
minimum for financial-services contexts.
|
||||
4. Internal post-mortem: what broke, what was over-budget, what would
|
||||
you do differently. Feed back into this runbook.
|
||||
|
||||
## Risk register
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| Wrong destination address on a move | Two independent humans verify the address against the wallet's `pkg/wallet/registry.go` record before approval. The receiving wallet also must already have a recent successful no-op signature in its history. |
|
||||
| Stuck Bitcoin transfer | `pkg/automation/feebump.go` RBF helper — every BTC tx is RBF-flagged at build time |
|
||||
| EVM nonce gap or stuck tx | `pkg/automation/feebump.go` EIP-1559 fee bump using the existing nonce |
|
||||
| Compromised signer during migration | All signers WebAuthn-bound to their actual hardware; biometric enrollment LAX/STRICT toggle (`MPC_LIVENESS_BINDING`) — keep STRICT during migration |
|
||||
| Webhook downtime → ops blind | Run old + new in parallel for one reporting cycle; don't decommission Fireblocks webhooks until lux/mpc webhook delivery has logged a clean cycle |
|
||||
| Auditor pushback on chain-of-custody | Audit log Merkle-chain + M-Chain anchor (`pkg/audit/mchain.go`) is replayable end-to-end |
|
||||
|
||||
## Timeline summary
|
||||
|
||||
| Week | Phase | Owner | Exit criterion |
|
||||
|---|---|---|---|
|
||||
| 0 | Pre-flight + Phase 1 inventory | platform + compliance | tier-mapping.md merged |
|
||||
| 1 | Phase 2 wallet creation (batch 1: low-value) | platform | First batch DKG + verification done |
|
||||
| 2 | Phase 2 wallet creation (batch 2: high-value cold) + Phase 3 policy migration | platform + compliance | All wallets exist, all rules ported |
|
||||
| 3 | Phase 4 asset transfers (high-frequency wallets first) + Phase 5 integration cutover | platform + treasury + engineering | Trading desk live on lux/mpc |
|
||||
| 4 | Phase 4 asset transfers (cold + treasury) | treasury | All funds on lux/mpc, parallel-run mode |
|
||||
| 5 | Phase 6 compliance handover | compliance | Insurance + audit signed off |
|
||||
| 6 | Phase 7 decommission | platform | Old workspace deleted, evidence archived |
|
||||
|
||||
For workspaces under ~$200M AUM, weeks 3 and 4 collapse into one. For
|
||||
workspaces over ~$5B AUM, plan for 8 weeks instead of 6 — not because
|
||||
the technology takes longer, but because your auditor will want longer
|
||||
between batches.
|
||||
|
||||
## What you keep, what you lose
|
||||
|
||||
You keep:
|
||||
- Every dollar of value (no haircut, no spread)
|
||||
- Every TAP rule, every approver, every allowlist entry
|
||||
- Chainalysis screening (same vendor, your API key)
|
||||
- WebAuthn / mobile approval (with new device enrollments)
|
||||
|
||||
You lose:
|
||||
- Fireblocks-arranged insurance policy (re-underwrite via your broker)
|
||||
- Fireblocks "Reactor" forensic UI (use Chainalysis Reactor directly,
|
||||
or our policy-engine query surface)
|
||||
- MPC-CMP-specific quirks (we use CGGMP21 — same family, public spec)
|
||||
|
||||
You gain:
|
||||
- Open-source codebase you can read, fork, and audit
|
||||
- Air-gapped signing as a first-class flow (`pkg/airgap/`)
|
||||
- 9-tier wallet model with cryptographic domain separation
|
||||
- Per-node policy verification (no quorum-bypass risk)
|
||||
- Post-quantum approval providers (`pkg/approval/provider_mldsa.go`)
|
||||
- Treasury 3-of-5 with regulator shard built in
|
||||
- M-Chain audit anchoring
|
||||
|
||||
## Support
|
||||
|
||||
- Architecture questions: cto@hanzo.ai
|
||||
- Operational runbook: ops@hanzo.ai
|
||||
- Compliance / audit: compliance@hanzo.ai
|
||||
|
||||
This document is the single source of truth for the migration. If
|
||||
something contradicts it, this document wins; submit a PR to update it.
|
||||
+12
-3
@@ -124,14 +124,22 @@ func main() {
|
||||
},
|
||||
// HSM signer flags (for co-signing)
|
||||
&cli.StringFlag{
|
||||
Name: "hsm-signer",
|
||||
Usage: "Signer provider for intent co-signing: aws|gcp|azure|zymbit|mldsa|local (default: local)",
|
||||
Name: "hsm-signer",
|
||||
Usage: "Signer provider for intent co-signing. " +
|
||||
"Cloud KMS: aws|gcp|azure. " +
|
||||
"Network HSM: zymbit|gridplus. " +
|
||||
"USB HSM: yubihsm|nitrokey. " +
|
||||
"Universal PKCS#11 (Thales/Utimaco/Entrust/CloudHSM/SoftHSM2): pkcs11. " +
|
||||
"Airgapped wallets (require AirgapTransport): coldcard|foundation|keystone|ngrave. " +
|
||||
"Online wallets: ledger|trezor. " +
|
||||
"Post-quantum: mldsa. " +
|
||||
"Dev only: local (default).",
|
||||
Sources: cli.EnvVars("MPC_HSM_SIGNER"),
|
||||
Value: "local",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "hsm-signer-key-id",
|
||||
Usage: "HSM signer key ARN/name for co-signing operations",
|
||||
Usage: "HSM signer key ARN/name/object-ID for co-signing operations",
|
||||
Sources: cli.EnvVars("MPC_HSM_SIGNER_KEY_ID"),
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
@@ -158,6 +166,7 @@ func main() {
|
||||
return nil
|
||||
},
|
||||
},
|
||||
airgapCommand(),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
package automation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"math/big"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
// GasStation
|
||||
// =============================================================================
|
||||
|
||||
type fakeChain struct {
|
||||
balances map[string]*big.Int // addr -> wei
|
||||
sentLog []sentTx
|
||||
balanceErr error
|
||||
sendErr error
|
||||
}
|
||||
|
||||
type sentTx struct {
|
||||
chain, from, to string
|
||||
amount *big.Int
|
||||
}
|
||||
|
||||
func (f *fakeChain) Balance(_ context.Context, _, addr string) (*big.Int, error) {
|
||||
if f.balanceErr != nil {
|
||||
return nil, f.balanceErr
|
||||
}
|
||||
if v, ok := f.balances[addr]; ok {
|
||||
return new(big.Int).Set(v), nil
|
||||
}
|
||||
return new(big.Int), nil
|
||||
}
|
||||
|
||||
func (f *fakeChain) SendValue(_ context.Context, chain, from, to string, amt *big.Int) (string, error) {
|
||||
if f.sendErr != nil {
|
||||
return "", f.sendErr
|
||||
}
|
||||
f.sentLog = append(f.sentLog, sentTx{chain: chain, from: from, to: to, amount: new(big.Int).Set(amt)})
|
||||
if f.balances == nil {
|
||||
f.balances = map[string]*big.Int{}
|
||||
}
|
||||
cur := f.balances[to]
|
||||
if cur == nil {
|
||||
cur = new(big.Int)
|
||||
}
|
||||
f.balances[to] = new(big.Int).Add(cur, amt)
|
||||
return "0xtest", nil
|
||||
}
|
||||
|
||||
func TestGasStation_TopUpBelowFloor(t *testing.T) {
|
||||
chain := &fakeChain{balances: map[string]*big.Int{
|
||||
"hot1": big.NewInt(50),
|
||||
}}
|
||||
g := NewGasStation(chain)
|
||||
if err := g.Set(GasRule{
|
||||
OrgID: "org1",
|
||||
Chain: "ethereum",
|
||||
ReserveWallet: "reserve",
|
||||
TargetWallets: []string{"hot1"},
|
||||
Floor: big.NewInt(100),
|
||||
Target: big.NewInt(500),
|
||||
CapPerTick: big.NewInt(10000),
|
||||
Enabled: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("Set: %v", err)
|
||||
}
|
||||
results, err := g.Tick(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Tick: %v", err)
|
||||
}
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("want 1 result, got %d", len(results))
|
||||
}
|
||||
r := results[0]
|
||||
if len(r.Receipts) != 1 || r.Receipts[0].SkipReason != "" {
|
||||
t.Fatalf("want one successful topup, got %+v", r.Receipts)
|
||||
}
|
||||
if r.Receipts[0].SentWei != "450" {
|
||||
t.Errorf("want sent=450 (target500 - bal50), got %s", r.Receipts[0].SentWei)
|
||||
}
|
||||
if len(chain.sentLog) != 1 || chain.sentLog[0].amount.Cmp(big.NewInt(450)) != 0 {
|
||||
t.Errorf("send log mismatch: %+v", chain.sentLog)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGasStation_AboveFloorSkipped(t *testing.T) {
|
||||
chain := &fakeChain{balances: map[string]*big.Int{"hot1": big.NewInt(200)}}
|
||||
g := NewGasStation(chain)
|
||||
_ = g.Set(GasRule{
|
||||
OrgID: "org1", Chain: "ethereum", ReserveWallet: "reserve",
|
||||
TargetWallets: []string{"hot1"},
|
||||
Floor: big.NewInt(100), Target: big.NewInt(500), CapPerTick: big.NewInt(10000),
|
||||
Enabled: true,
|
||||
})
|
||||
results, err := g.Tick(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Tick: %v", err)
|
||||
}
|
||||
if results[0].Receipts[0].SkipReason != "above floor" {
|
||||
t.Errorf("want skip 'above floor', got %q", results[0].Receipts[0].SkipReason)
|
||||
}
|
||||
if len(chain.sentLog) != 0 {
|
||||
t.Errorf("expected no send, got %+v", chain.sentLog)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGasStation_CapPerTickHonored(t *testing.T) {
|
||||
chain := &fakeChain{balances: map[string]*big.Int{"hot1": big.NewInt(0)}}
|
||||
g := NewGasStation(chain)
|
||||
_ = g.Set(GasRule{
|
||||
OrgID: "org1", Chain: "ethereum", ReserveWallet: "reserve",
|
||||
TargetWallets: []string{"hot1"},
|
||||
Floor: big.NewInt(100), Target: big.NewInt(10000), CapPerTick: big.NewInt(50),
|
||||
Enabled: true,
|
||||
})
|
||||
results, _ := g.Tick(context.Background())
|
||||
if got := results[0].Receipts[0].SentWei; got != "50" {
|
||||
t.Errorf("cap not honored — sent %s, want 50", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGasStation_ValidateRejectsBadRule(t *testing.T) {
|
||||
bad := GasRule{OrgID: "x", Chain: "y", ReserveWallet: "r", TargetWallets: []string{"a"},
|
||||
Floor: big.NewInt(10), Target: big.NewInt(5), CapPerTick: big.NewInt(1)}
|
||||
if err := bad.Validate(); err == nil {
|
||||
t.Error("expected target<=floor to fail validation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGasStation_BalanceError(t *testing.T) {
|
||||
chain := &fakeChain{balanceErr: errors.New("rpc down")}
|
||||
g := NewGasStation(chain)
|
||||
_ = g.Set(GasRule{
|
||||
OrgID: "org1", Chain: "ethereum", ReserveWallet: "reserve",
|
||||
TargetWallets: []string{"hot1"},
|
||||
Floor: big.NewInt(100), Target: big.NewInt(500), CapPerTick: big.NewInt(10000),
|
||||
Enabled: true,
|
||||
})
|
||||
results, _ := g.Tick(context.Background())
|
||||
if results[0].Receipts[0].SkipReason == "" {
|
||||
t.Error("expected SkipReason on balance error")
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// SmartTransfer
|
||||
// =============================================================================
|
||||
|
||||
func TestSmartTransfer_PicksCheapest(t *testing.T) {
|
||||
st := NewSmartTransfer()
|
||||
st.Add(NewOnChainProvider("ethereum", 50, 30*time.Second))
|
||||
st.Add(NewBridgeProvider("luxbridge", "ethereum", "lux", 25, 5*time.Minute))
|
||||
|
||||
req := TransferRequest{
|
||||
OrgID: "org1",
|
||||
Asset: "USDC",
|
||||
Amount: big.NewInt(1_000_000),
|
||||
From: "0xfrom",
|
||||
To: "0xto",
|
||||
}
|
||||
best, err := st.Best(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("Best: %v", err)
|
||||
}
|
||||
if best.FeeBps != 25 {
|
||||
t.Errorf("want cheapest 25bps, got %d", best.FeeBps)
|
||||
}
|
||||
quotes, _ := st.Quote(context.Background(), req)
|
||||
if len(quotes) != 2 || quotes[0].FeeBps > quotes[1].FeeBps {
|
||||
t.Errorf("quotes not sorted: %+v", quotes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSmartTransfer_NoProviders(t *testing.T) {
|
||||
st := NewSmartTransfer()
|
||||
_, err := st.Best(context.Background(), TransferRequest{
|
||||
OrgID: "x",
|
||||
Asset: "A",
|
||||
Amount: big.NewInt(1),
|
||||
From: "f",
|
||||
To: "t",
|
||||
})
|
||||
if err == nil {
|
||||
t.Error("expected error with zero providers")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSmartTransfer_RejectsBadRequest(t *testing.T) {
|
||||
st := NewSmartTransfer()
|
||||
st.Add(NewOnChainProvider("ethereum", 5, time.Second))
|
||||
_, err := st.Best(context.Background(), TransferRequest{Asset: "A"})
|
||||
if err == nil {
|
||||
t.Error("expected validation error for missing fields")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSmartTransfer_OnChainFeeAbsCorrect(t *testing.T) {
|
||||
st := NewSmartTransfer()
|
||||
st.Add(NewOnChainProvider("ethereum", 50, time.Second)) // 0.5%
|
||||
q, _ := st.Best(context.Background(), TransferRequest{
|
||||
OrgID: "o", Asset: "A", From: "f", To: "t",
|
||||
Amount: big.NewInt(1_000_000),
|
||||
})
|
||||
// 0.5% of 1_000_000 = 5_000.
|
||||
if q.FeeAbs != "5000" {
|
||||
t.Errorf("want feeAbs=5000, got %s", q.FeeAbs)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// FeeBump — EVM
|
||||
// =============================================================================
|
||||
|
||||
func TestEVMBump_Legacy(t *testing.T) {
|
||||
in := EVMBumpInput{GasPrice: big.NewInt(20_000_000_000)} // 20 gwei
|
||||
plan, err := EVMBump(in, 2500) // 25%
|
||||
if err != nil {
|
||||
t.Fatalf("EVMBump: %v", err)
|
||||
}
|
||||
if plan.Mode != "legacy" {
|
||||
t.Errorf("want mode legacy, got %s", plan.Mode)
|
||||
}
|
||||
want := big.NewInt(25_000_000_000)
|
||||
if plan.GasPrice.Cmp(want) != 0 {
|
||||
t.Errorf("want gasPrice %s, got %s", want, plan.GasPrice)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEVMBump_EIP1559(t *testing.T) {
|
||||
in := EVMBumpInput{
|
||||
MaxFeePerGas: big.NewInt(30_000_000_000),
|
||||
MaxPriorityFeePerGas: big.NewInt(2_000_000_000),
|
||||
}
|
||||
plan, err := EVMBump(in, 1000) // 10%
|
||||
if err != nil {
|
||||
t.Fatalf("EVMBump: %v", err)
|
||||
}
|
||||
if plan.Mode != "eip1559" {
|
||||
t.Errorf("want eip1559, got %s", plan.Mode)
|
||||
}
|
||||
if plan.MaxFeePerGas.Cmp(big.NewInt(33_000_000_000)) != 0 {
|
||||
t.Errorf("max fee not bumped: %s", plan.MaxFeePerGas)
|
||||
}
|
||||
if plan.MaxPriorityFeePerGas.Cmp(big.NewInt(2_200_000_000)) != 0 {
|
||||
t.Errorf("priority not bumped: %s", plan.MaxPriorityFeePerGas)
|
||||
}
|
||||
// Invariant: max >= priority.
|
||||
if plan.MaxFeePerGas.Cmp(plan.MaxPriorityFeePerGas) < 0 {
|
||||
t.Error("invariant violated: maxFee < priority")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEVMBump_NoFeeFields(t *testing.T) {
|
||||
if _, err := EVMBump(EVMBumpInput{}, 1000); err == nil {
|
||||
t.Error("expected error with no fee fields")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEVMBump_PreservesInvariantWhenPriorityExceedsMax(t *testing.T) {
|
||||
// Edge case: caller already in a bad state. Bump must heal.
|
||||
in := EVMBumpInput{
|
||||
MaxFeePerGas: big.NewInt(10),
|
||||
MaxPriorityFeePerGas: big.NewInt(50),
|
||||
}
|
||||
plan, _ := EVMBump(in, 1000)
|
||||
if plan.MaxFeePerGas.Cmp(plan.MaxPriorityFeePerGas) < 0 {
|
||||
t.Errorf("bump did not heal invariant: max=%s prio=%s",
|
||||
plan.MaxFeePerGas, plan.MaxPriorityFeePerGas)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// FeeBump — BTC
|
||||
// =============================================================================
|
||||
|
||||
func TestBTCBump_AppliesBumpAboveFloor(t *testing.T) {
|
||||
in := BTCBumpInput{
|
||||
OldFeeSat: big.NewInt(1500),
|
||||
VSize: 150,
|
||||
MinFeeRateSatVB: 1,
|
||||
IncrementSatVB: 1,
|
||||
Inputs: []string{"abc:0"},
|
||||
NSequenceMaxAll: 0x00000000, // RBF
|
||||
}
|
||||
plan, err := BTCBump(in, 2500) // 25%
|
||||
if err != nil {
|
||||
t.Fatalf("BTCBump: %v", err)
|
||||
}
|
||||
// 25% of 1500 = 375; 1500 + 375 = 1875.
|
||||
// Floor bump = 1*150 = 150 -> 1650. Bumped (1875) > floor — bumped wins.
|
||||
if plan.NewFeeSat.Cmp(big.NewInt(1875)) != 0 {
|
||||
t.Errorf("want fee 1875, got %s", plan.NewFeeSat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBTCBump_RejectsNonRBFInputs(t *testing.T) {
|
||||
in := BTCBumpInput{
|
||||
OldFeeSat: big.NewInt(1500),
|
||||
VSize: 150,
|
||||
NSequenceMaxAll: 0xfffffffe, // explicitly non-RBF
|
||||
}
|
||||
if _, err := BTCBump(in, 1000); err == nil {
|
||||
t.Error("expected error for non-RBF inputs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBTCBump_RaisesToNetworkMinimum(t *testing.T) {
|
||||
in := BTCBumpInput{
|
||||
OldFeeSat: big.NewInt(100),
|
||||
VSize: 150,
|
||||
MinFeeRateSatVB: 5, // mempool floor 5 sat/vB
|
||||
NSequenceMaxAll: 0x00,
|
||||
}
|
||||
plan, err := BTCBump(in, 1000) // 10%
|
||||
if err != nil {
|
||||
t.Fatalf("BTCBump: %v", err)
|
||||
}
|
||||
// 10% of 100 = 10; 100+10=110 -> rate 0 sat/vB, below 5.
|
||||
// Must raise to 5*150 = 750.
|
||||
if plan.NewFeeRateSatVB != 5 {
|
||||
t.Errorf("want rate 5, got %d", plan.NewFeeRateSatVB)
|
||||
}
|
||||
if plan.NewFeeSat.Cmp(big.NewInt(750)) != 0 {
|
||||
t.Errorf("want fee 750, got %s", plan.NewFeeSat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBTCBump_RejectsBadInputs(t *testing.T) {
|
||||
cases := []BTCBumpInput{
|
||||
{OldFeeSat: nil, VSize: 100, NSequenceMaxAll: 0},
|
||||
{OldFeeSat: big.NewInt(0), VSize: 100, NSequenceMaxAll: 0},
|
||||
{OldFeeSat: big.NewInt(100), VSize: 0, NSequenceMaxAll: 0},
|
||||
}
|
||||
for i, c := range cases {
|
||||
if _, err := BTCBump(c, 1000); err == nil {
|
||||
t.Errorf("case %d: expected error", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatPlan(t *testing.T) {
|
||||
evm := EVMBumpPlan{Mode: "legacy", GasPrice: big.NewInt(25_000_000_000), BumpFactorBps: 2500}
|
||||
if got := FormatPlan(evm); got == "" {
|
||||
t.Error("FormatPlan(EVMBumpPlan) empty")
|
||||
}
|
||||
btc := BTCBumpPlan{NewFeeSat: big.NewInt(1875), NewFeeRateSatVB: 12, BumpFactorBps: 2500}
|
||||
if got := FormatPlan(btc); got == "" {
|
||||
t.Error("FormatPlan(BTCBumpPlan) empty")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// Package automation implements the operational automations that close the
|
||||
// last gap between Fireblocks-class custody and lux/mpc:
|
||||
//
|
||||
// - GasStation — keep operational ("hot") wallets topped up so signing
|
||||
// never blocks on fee insufficiency. Source-of-funds is a configured
|
||||
// reserve wallet; per-asset thresholds and caps are tier-driven.
|
||||
//
|
||||
// - SmartTransfer — pick the cheapest route across configured providers
|
||||
// for a given (asset, amount, destination). Routes are scored by total
|
||||
// cost (fee + gas + slippage estimate) and the lowest-cost route wins.
|
||||
//
|
||||
// - FeeBump — re-broadcast a stuck transaction with a higher fee.
|
||||
// Bitcoin: RBF (BIP-125) — replace the input UTXO set unchanged, raise
|
||||
// the explicit fee. EVM: same nonce, raise gasPrice or maxFeePerGas
|
||||
// by the configured bump factor.
|
||||
//
|
||||
// All three are deliberately small: they are *policies* on top of the
|
||||
// existing /v1/mpc/sign + /v1/mpc/wallets/sweep primitives, not new wire
|
||||
// protocols. None of them call external SaaS. They are pure-Go and rely
|
||||
// only on stdlib + math/big. Wire them up by injecting:
|
||||
//
|
||||
// - a Signer (an MPC-backed thing that signs transactions for a wallet)
|
||||
// - a ChainClient (anything that can read balances and broadcast txs)
|
||||
//
|
||||
// Both interfaces are local to this package — no cross-package coupling
|
||||
// to pkg/api or pkg/mpc — so the package is testable in isolation and
|
||||
// composable into any orchestrator (cron, the API scheduler, an external
|
||||
// operator) without rewiring.
|
||||
package automation
|
||||
@@ -0,0 +1,188 @@
|
||||
// FeeBump: re-broadcast a stuck transaction at a higher effective fee.
|
||||
//
|
||||
// EVM (gas-priced chains): same nonce, raise gasPrice (or maxFeePerGas
|
||||
// + maxPriorityFeePerGas for EIP-1559). Geth and most relays honor the
|
||||
// higher-fee replacement when the bump exceeds 10%.
|
||||
//
|
||||
// Bitcoin (BIP-125 RBF): same input set, higher explicit fee, opt-in via
|
||||
// nSequence < 0xfffffffe on every input. Replacement requires the new fee
|
||||
// rate to exceed the old by at least the relay's incrementalRelayFee.
|
||||
//
|
||||
// FeeBump is a *plan* generator. It computes the new fee values and the
|
||||
// delta evidence; the caller signs and broadcasts via the existing MPC
|
||||
// path. We do not embed a chain client here — the same plan is used by
|
||||
// the in-process scheduler and by external operators running tooling.
|
||||
|
||||
package automation
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math/big"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// EVMBumpInput is the existing fee state of a stuck EVM transaction.
|
||||
type EVMBumpInput struct {
|
||||
GasPrice *big.Int `json:"gasPrice,omitempty"` // legacy
|
||||
MaxFeePerGas *big.Int `json:"maxFeePerGas,omitempty"` // 1559
|
||||
MaxPriorityFeePerGas *big.Int `json:"maxPriorityFeePerGas,omitempty"` // 1559
|
||||
}
|
||||
|
||||
// EVMBumpPlan is what the caller signs and rebroadcasts with the same
|
||||
// nonce. At least one of the legacy / 1559 sets is populated based on
|
||||
// what the input carried.
|
||||
type EVMBumpPlan struct {
|
||||
Mode string `json:"mode"` // "legacy" | "eip1559"
|
||||
GasPrice *big.Int `json:"gasPrice,omitempty"`
|
||||
MaxFeePerGas *big.Int `json:"maxFeePerGas,omitempty"`
|
||||
MaxPriorityFeePerGas *big.Int `json:"maxPriorityFeePerGas,omitempty"`
|
||||
BumpFactorBps int64 `json:"bumpFactorBps"` // applied bump in basis points
|
||||
}
|
||||
|
||||
// EVMBump computes a fee-bumped plan from the current fee state.
|
||||
//
|
||||
// bumpBps applies to whichever fields are non-nil. Geth's default
|
||||
// minimum is 1000 bps (10%); operators tune higher (e.g. 2500 = 25%) for
|
||||
// volatile gas regimes. bumpBps must be > 0.
|
||||
func EVMBump(in EVMBumpInput, bumpBps int64) (EVMBumpPlan, error) {
|
||||
if bumpBps <= 0 {
|
||||
return EVMBumpPlan{}, errors.New("automation: bumpBps must be positive")
|
||||
}
|
||||
plan := EVMBumpPlan{BumpFactorBps: bumpBps}
|
||||
|
||||
// EIP-1559 path: both fields present.
|
||||
if in.MaxFeePerGas != nil && in.MaxPriorityFeePerGas != nil {
|
||||
plan.Mode = "eip1559"
|
||||
plan.MaxFeePerGas = applyBps(in.MaxFeePerGas, bumpBps)
|
||||
plan.MaxPriorityFeePerGas = applyBps(in.MaxPriorityFeePerGas, bumpBps)
|
||||
// Invariant: maxFeePerGas >= maxPriorityFeePerGas. EIP-1559
|
||||
// rejects otherwise. If the bump pushed priority above max,
|
||||
// raise max to match.
|
||||
if plan.MaxFeePerGas.Cmp(plan.MaxPriorityFeePerGas) < 0 {
|
||||
plan.MaxFeePerGas = new(big.Int).Set(plan.MaxPriorityFeePerGas)
|
||||
}
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
// Legacy path.
|
||||
if in.GasPrice != nil {
|
||||
plan.Mode = "legacy"
|
||||
plan.GasPrice = applyBps(in.GasPrice, bumpBps)
|
||||
return plan, nil
|
||||
}
|
||||
return EVMBumpPlan{}, errors.New("automation: EVMBump needs gasPrice or 1559 fee fields")
|
||||
}
|
||||
|
||||
// BTCBumpInput is the existing fee state of a stuck Bitcoin transaction.
|
||||
type BTCBumpInput struct {
|
||||
OldFeeSat *big.Int `json:"oldFeeSat"` // current absolute fee in satoshis
|
||||
VSize int64 `json:"vSize"` // virtual size (bytes); used to convert sat/vB
|
||||
MinFeeRateSatVB int64 `json:"minFeeRateSatVB"` // network minimum (mempool floor)
|
||||
IncrementSatVB int64 `json:"incrementSatVB"` // node's incrementalRelayFee, default 1
|
||||
Inputs []string `json:"inputs"` // outpoints — mirrored unchanged
|
||||
NSequenceMaxAll uint32 `json:"nSequenceMaxAll"` // every input's nSequence — must be <0xfffffffe for RBF
|
||||
}
|
||||
|
||||
// BTCBumpPlan is the new fee + the inputs (unchanged) that the caller
|
||||
// must sign into a replacement transaction.
|
||||
type BTCBumpPlan struct {
|
||||
NewFeeSat *big.Int `json:"newFeeSat"`
|
||||
NewFeeRateSatVB int64 `json:"newFeeRateSatVB"`
|
||||
Inputs []string `json:"inputs"`
|
||||
BumpFactorBps int64 `json:"bumpFactorBps"`
|
||||
}
|
||||
|
||||
// BTCBump computes an RBF-compliant replacement fee.
|
||||
//
|
||||
// Rules:
|
||||
// - All inputs must already be RBF-flagged (nSequence < 0xfffffffe).
|
||||
// - New fee must exceed old fee by IncrementSatVB * vsize, at minimum.
|
||||
// - Resulting fee rate must be >= MinFeeRateSatVB.
|
||||
// - bumpBps applies on top — operators set this above the relay
|
||||
// increment to make the replacement attractive to miners.
|
||||
func BTCBump(in BTCBumpInput, bumpBps int64) (BTCBumpPlan, error) {
|
||||
if bumpBps <= 0 {
|
||||
return BTCBumpPlan{}, errors.New("automation: bumpBps must be positive")
|
||||
}
|
||||
if in.OldFeeSat == nil || in.OldFeeSat.Sign() <= 0 {
|
||||
return BTCBumpPlan{}, errors.New("automation: BTCBump needs positive oldFeeSat")
|
||||
}
|
||||
if in.VSize <= 0 {
|
||||
return BTCBumpPlan{}, errors.New("automation: BTCBump needs positive vSize")
|
||||
}
|
||||
if in.NSequenceMaxAll >= 0xfffffffe {
|
||||
return BTCBumpPlan{}, errors.New("automation: BTCBump requires RBF-flagged inputs (nSequence < 0xfffffffe)")
|
||||
}
|
||||
inc := in.IncrementSatVB
|
||||
if inc <= 0 {
|
||||
inc = 1
|
||||
}
|
||||
|
||||
// Floor: bump every input's required minimum (IncrementSatVB * vsize).
|
||||
floorBump := new(big.Int).Mul(big.NewInt(inc), big.NewInt(in.VSize))
|
||||
floorFee := new(big.Int).Add(in.OldFeeSat, floorBump)
|
||||
|
||||
// Bump-factor candidate.
|
||||
bumpedFee := applyBps(in.OldFeeSat, bumpBps)
|
||||
newFee := bumpedFee
|
||||
if floorFee.Cmp(newFee) > 0 {
|
||||
newFee = floorFee
|
||||
}
|
||||
|
||||
// Network minimum check.
|
||||
newFeeRate := new(big.Int).Quo(newFee, big.NewInt(in.VSize)).Int64()
|
||||
if newFeeRate < in.MinFeeRateSatVB {
|
||||
// Raise to the floor explicitly.
|
||||
newFeeRate = in.MinFeeRateSatVB
|
||||
newFee = new(big.Int).Mul(big.NewInt(newFeeRate), big.NewInt(in.VSize))
|
||||
}
|
||||
|
||||
return BTCBumpPlan{
|
||||
NewFeeSat: newFee,
|
||||
NewFeeRateSatVB: newFeeRate,
|
||||
Inputs: append([]string(nil), in.Inputs...),
|
||||
BumpFactorBps: bumpBps,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// applyBps returns base + base*bps/10000 — i.e. base scaled up by bps
|
||||
// basis points. Used by both EVM and BTC paths.
|
||||
func applyBps(base *big.Int, bps int64) *big.Int {
|
||||
delta := new(big.Int).Mul(base, big.NewInt(bps))
|
||||
delta.Quo(delta, big.NewInt(10000))
|
||||
out := new(big.Int).Add(base, delta)
|
||||
return out
|
||||
}
|
||||
|
||||
// FormatPlan returns a one-line operator-friendly description of a plan.
|
||||
// EVM: "legacy gasPrice 21->27 gwei (bump 2500bps)" or
|
||||
// "eip1559 maxFee 30->37, prio 1->2 gwei (bump 2500bps)".
|
||||
// BTC: "rbf 1500->2200 sat (rate 6 sat/vB, bump 2500bps)".
|
||||
func FormatPlan(plan interface{}) string {
|
||||
switch p := plan.(type) {
|
||||
case EVMBumpPlan:
|
||||
var b strings.Builder
|
||||
b.WriteString("evm-")
|
||||
b.WriteString(p.Mode)
|
||||
if p.GasPrice != nil {
|
||||
b.WriteString(" gasPrice=")
|
||||
b.WriteString(p.GasPrice.String())
|
||||
}
|
||||
if p.MaxFeePerGas != nil {
|
||||
b.WriteString(" maxFee=")
|
||||
b.WriteString(p.MaxFeePerGas.String())
|
||||
}
|
||||
if p.MaxPriorityFeePerGas != nil {
|
||||
b.WriteString(" priority=")
|
||||
b.WriteString(p.MaxPriorityFeePerGas.String())
|
||||
}
|
||||
return b.String()
|
||||
case BTCBumpPlan:
|
||||
var b strings.Builder
|
||||
b.WriteString("btc-rbf fee=")
|
||||
b.WriteString(p.NewFeeSat.String())
|
||||
return b.String()
|
||||
default:
|
||||
return "unknown plan"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
// GasStation: keep configured operational wallets topped up to a target
|
||||
// balance whenever they fall under a configured floor. The reserve wallet
|
||||
// is the source of funds; per-asset rules carry the floor, the target, and
|
||||
// a hard cap on how much may flow per Tick.
|
||||
//
|
||||
// One reserve wallet per (orgId, chain). Multiple operational wallets per
|
||||
// rule. Topup amount is min(target - balance, capPerTick). Below floor:
|
||||
// schedule a topup. Above floor: do nothing.
|
||||
|
||||
package automation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ChainClient reads balances and broadcasts pre-signed transactions. The
|
||||
// concrete implementation in production wraps an EVM RPC node, a Bitcoin
|
||||
// node, etc. The package is agnostic.
|
||||
type ChainClient interface {
|
||||
// Balance returns the current balance of address on chain (smallest
|
||||
// unit — wei for EVM, satoshi for BTC).
|
||||
Balance(ctx context.Context, chain, address string) (*big.Int, error)
|
||||
|
||||
// SendValue builds, signs (via the Signer), and broadcasts a value
|
||||
// transfer of amount from→to on chain. Returns the tx hash. The
|
||||
// implementation chooses fee strategy.
|
||||
SendValue(ctx context.Context, chain, from, to string, amount *big.Int) (string, error)
|
||||
}
|
||||
|
||||
// GasRule is one operational-wallet top-up rule.
|
||||
type GasRule struct {
|
||||
OrgID string `json:"orgId"`
|
||||
Chain string `json:"chain"`
|
||||
ReserveWallet string `json:"reserveWallet"` // address — source of funds
|
||||
TargetWallets []string `json:"targetWallets"` // addresses to top up
|
||||
Floor *big.Int `json:"floor"` // top up when balance < floor
|
||||
Target *big.Int `json:"target"` // top up to this level
|
||||
CapPerTick *big.Int `json:"capPerTick"` // never send more than this in a single topup
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// Validate enforces the invariants every rule must satisfy.
|
||||
func (r GasRule) Validate() error {
|
||||
if r.OrgID == "" || r.Chain == "" {
|
||||
return errors.New("automation: GasRule needs orgId and chain")
|
||||
}
|
||||
if r.ReserveWallet == "" {
|
||||
return errors.New("automation: GasRule needs reserveWallet")
|
||||
}
|
||||
if len(r.TargetWallets) == 0 {
|
||||
return errors.New("automation: GasRule needs at least one target wallet")
|
||||
}
|
||||
if r.Floor == nil || r.Target == nil || r.CapPerTick == nil {
|
||||
return errors.New("automation: GasRule needs floor, target, and capPerTick")
|
||||
}
|
||||
if r.Floor.Sign() < 0 || r.Target.Sign() < 0 || r.CapPerTick.Sign() <= 0 {
|
||||
return errors.New("automation: GasRule values must be non-negative (cap > 0)")
|
||||
}
|
||||
if r.Target.Cmp(r.Floor) <= 0 {
|
||||
return errors.New("automation: GasRule target must exceed floor")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TopupReceipt records what a Tick did for one address.
|
||||
type TopupReceipt struct {
|
||||
Address string `json:"address"`
|
||||
BeforeWei string `json:"beforeWei"`
|
||||
AfterWei string `json:"afterWei,omitempty"`
|
||||
SentWei string `json:"sentWei"`
|
||||
TxHash string `json:"txHash"`
|
||||
SkipReason string `json:"skipReason,omitempty"`
|
||||
OccurredAt time.Time `json:"occurredAt"`
|
||||
}
|
||||
|
||||
// TickResult is the per-rule outcome of one GasStation tick.
|
||||
type TickResult struct {
|
||||
OrgID string `json:"orgId"`
|
||||
Chain string `json:"chain"`
|
||||
Receipts []TopupReceipt `json:"receipts"`
|
||||
}
|
||||
|
||||
// GasStation runs gas-station rules on demand or on a ticker.
|
||||
type GasStation struct {
|
||||
client ChainClient
|
||||
|
||||
mu sync.RWMutex
|
||||
rules map[string]GasRule // key = orgId|chain|reserveWallet
|
||||
}
|
||||
|
||||
// NewGasStation constructs an empty station. Rules are added via Set; the
|
||||
// caller drives the loop with Tick (or starts Run for a periodic loop).
|
||||
func NewGasStation(client ChainClient) *GasStation {
|
||||
if client == nil {
|
||||
panic("automation: GasStation requires a non-nil ChainClient")
|
||||
}
|
||||
return &GasStation{client: client, rules: make(map[string]GasRule)}
|
||||
}
|
||||
|
||||
func ruleKey(orgID, chain, reserve string) string {
|
||||
return orgID + "|" + chain + "|" + reserve
|
||||
}
|
||||
|
||||
// Set adds or replaces a rule. Validates before storing.
|
||||
func (g *GasStation) Set(rule GasRule) error {
|
||||
if err := rule.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.rules[ruleKey(rule.OrgID, rule.Chain, rule.ReserveWallet)] = rule
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes a rule. Missing keys are a no-op.
|
||||
func (g *GasStation) Delete(orgID, chain, reserve string) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
delete(g.rules, ruleKey(orgID, chain, reserve))
|
||||
}
|
||||
|
||||
// List returns rules sorted by (orgId, chain, reserve).
|
||||
func (g *GasStation) List() []GasRule {
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
out := make([]GasRule, 0, len(g.rules))
|
||||
for _, r := range g.rules {
|
||||
out = append(out, r)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].OrgID != out[j].OrgID {
|
||||
return out[i].OrgID < out[j].OrgID
|
||||
}
|
||||
if out[i].Chain != out[j].Chain {
|
||||
return out[i].Chain < out[j].Chain
|
||||
}
|
||||
return out[i].ReserveWallet < out[j].ReserveWallet
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// Tick walks every enabled rule and tops up each target wallet whose
|
||||
// balance is under the rule's floor. Returns one TickResult per rule.
|
||||
//
|
||||
// Errors per-target are folded into the receipt's SkipReason — the
|
||||
// function only returns an error for systemic problems (no rules,
|
||||
// context cancellation).
|
||||
func (g *GasStation) Tick(ctx context.Context) ([]TickResult, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rules := g.List()
|
||||
if len(rules) == 0 {
|
||||
return nil, errors.New("automation: no gas-station rules configured")
|
||||
}
|
||||
out := make([]TickResult, 0, len(rules))
|
||||
for _, rule := range rules {
|
||||
if !rule.Enabled {
|
||||
continue
|
||||
}
|
||||
res := TickResult{OrgID: rule.OrgID, Chain: rule.Chain}
|
||||
for _, addr := range rule.TargetWallets {
|
||||
receipt := TopupReceipt{Address: addr, OccurredAt: time.Now().UTC()}
|
||||
bal, err := g.client.Balance(ctx, rule.Chain, addr)
|
||||
if err != nil {
|
||||
receipt.SkipReason = "balance: " + err.Error()
|
||||
res.Receipts = append(res.Receipts, receipt)
|
||||
continue
|
||||
}
|
||||
receipt.BeforeWei = bal.String()
|
||||
if bal.Cmp(rule.Floor) >= 0 {
|
||||
receipt.SkipReason = "above floor"
|
||||
receipt.SentWei = "0"
|
||||
res.Receipts = append(res.Receipts, receipt)
|
||||
continue
|
||||
}
|
||||
delta := new(big.Int).Sub(rule.Target, bal)
|
||||
if delta.Cmp(rule.CapPerTick) > 0 {
|
||||
delta = new(big.Int).Set(rule.CapPerTick)
|
||||
}
|
||||
tx, err := g.client.SendValue(ctx, rule.Chain, rule.ReserveWallet, addr, delta)
|
||||
if err != nil {
|
||||
receipt.SkipReason = "send: " + err.Error()
|
||||
receipt.SentWei = "0"
|
||||
res.Receipts = append(res.Receipts, receipt)
|
||||
continue
|
||||
}
|
||||
receipt.SentWei = delta.String()
|
||||
receipt.TxHash = tx
|
||||
res.Receipts = append(res.Receipts, receipt)
|
||||
}
|
||||
out = append(out, res)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Run drives Tick on the given interval until ctx is cancelled. Each tick
|
||||
// dispatches to handler with the result slice. Errors from Tick (other
|
||||
// than context cancellation) are passed to handler with results=nil.
|
||||
//
|
||||
// One tick at a time — the loop does not overlap. Operators set the
|
||||
// interval based on the chain's block time; 60s is a safe default for
|
||||
// EVM L1, 600s for Bitcoin.
|
||||
func (g *GasStation) Run(ctx context.Context, every time.Duration, handler func([]TickResult, error)) {
|
||||
if every <= 0 {
|
||||
panic("automation: GasStation.Run interval must be positive")
|
||||
}
|
||||
t := time.NewTicker(every)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
results, err := g.Tick(ctx)
|
||||
if handler != nil {
|
||||
handler(results, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// String pretty-prints a TickResult for log lines.
|
||||
func (r TickResult) String() string {
|
||||
return fmt.Sprintf("org=%s chain=%s receipts=%d", r.OrgID, r.Chain, len(r.Receipts))
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
// SmartTransfer: route a (asset, amount, destination) to the cheapest
|
||||
// configured provider. Each provider quotes a Cost (in a normalized
|
||||
// numéraire — by convention, basis points of the transferred amount).
|
||||
// The lowest-cost provider wins; ties are broken by provider name for
|
||||
// determinism.
|
||||
//
|
||||
// Providers may be:
|
||||
// - direct on-chain transfers per chain (one provider per chain)
|
||||
// - bridge routes (Lux Bridge, third-party bridges)
|
||||
// - off-chain settlement (transfer agency)
|
||||
//
|
||||
// SmartTransfer does not execute the transfer itself — it returns the
|
||||
// chosen route. The caller wires the route into the existing /v1/mpc/sign
|
||||
// or /v1/mpc/wallets/sweep path.
|
||||
|
||||
package automation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"math/big"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Quote is one provider's offer for moving (asset, amount) to dest.
|
||||
//
|
||||
// Cost is in basis points of amount (10000 = 100%). 5 bps = 0.05% — fine
|
||||
// for small amounts; for large transfers the absolute fee dominates and
|
||||
// providers should report it precisely.
|
||||
type Quote struct {
|
||||
Provider string `json:"provider"`
|
||||
Route string `json:"route"` // human-readable route description
|
||||
Asset string `json:"asset"`
|
||||
Amount string `json:"amount"` // base-10 in smallest unit
|
||||
Dest string `json:"dest"`
|
||||
Chain string `json:"chain"` // destination chain (CAIP-2)
|
||||
FeeBps int64 `json:"feeBps"` // total cost in basis points
|
||||
FeeAbs string `json:"feeAbs"` // absolute fee in smallest unit (informational)
|
||||
ETA time.Duration `json:"eta"` // estimated time to settle
|
||||
ValidUntil time.Time `json:"validUntil"`
|
||||
Provider_ Provider `json:"-"` // back-reference for execution
|
||||
}
|
||||
|
||||
// TransferRequest is what SmartTransfer.Quote consumes.
|
||||
type TransferRequest struct {
|
||||
OrgID string `json:"orgId"`
|
||||
Asset string `json:"asset"`
|
||||
Amount *big.Int `json:"amount"` // smallest unit
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
Chain string `json:"chain"` // optional preferred chain hint
|
||||
}
|
||||
|
||||
// Validate checks the required fields.
|
||||
func (r TransferRequest) Validate() error {
|
||||
if r.OrgID == "" {
|
||||
return errors.New("automation: SmartTransfer needs orgId")
|
||||
}
|
||||
if r.Asset == "" || r.From == "" || r.To == "" {
|
||||
return errors.New("automation: SmartTransfer needs asset, from, to")
|
||||
}
|
||||
if r.Amount == nil || r.Amount.Sign() <= 0 {
|
||||
return errors.New("automation: SmartTransfer needs positive amount")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Provider is one route source. Implementations include OnChainProvider,
|
||||
// BridgeProvider, and (in production) per-third-party-bridge providers.
|
||||
type Provider interface {
|
||||
Name() string
|
||||
// QuoteTransfer returns a Quote, or an error if this provider cannot
|
||||
// service the request. Errors are folded into "provider unavailable"
|
||||
// — they do not abort the multi-provider quote loop.
|
||||
QuoteTransfer(ctx context.Context, req TransferRequest) (Quote, error)
|
||||
}
|
||||
|
||||
// SmartTransfer aggregates Provider implementations and picks the cheapest.
|
||||
type SmartTransfer struct {
|
||||
mu sync.RWMutex
|
||||
providers map[string]Provider
|
||||
}
|
||||
|
||||
// NewSmartTransfer builds an empty router. Register providers via Add.
|
||||
func NewSmartTransfer() *SmartTransfer {
|
||||
return &SmartTransfer{providers: make(map[string]Provider)}
|
||||
}
|
||||
|
||||
// Add registers a provider. Replaces any existing provider with the same
|
||||
// Name() — providers are unique by name.
|
||||
func (s *SmartTransfer) Add(p Provider) {
|
||||
if p == nil {
|
||||
panic("automation: SmartTransfer.Add(nil)")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.providers[p.Name()] = p
|
||||
}
|
||||
|
||||
// Remove deregisters a provider. Missing names are a no-op.
|
||||
func (s *SmartTransfer) Remove(name string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
delete(s.providers, name)
|
||||
}
|
||||
|
||||
// Providers returns the registered provider names, sorted.
|
||||
func (s *SmartTransfer) Providers() []string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make([]string, 0, len(s.providers))
|
||||
for n := range s.providers {
|
||||
out = append(out, n)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// Quote asks every provider for a quote and returns the full slate sorted
|
||||
// by ascending FeeBps (cheapest first). Providers that error are dropped.
|
||||
//
|
||||
// Returns at least one quote on success. A request with zero providers
|
||||
// returns an error.
|
||||
func (s *SmartTransfer) Quote(ctx context.Context, req TransferRequest) ([]Quote, error) {
|
||||
if err := req.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.mu.RLock()
|
||||
provs := make([]Provider, 0, len(s.providers))
|
||||
for _, p := range s.providers {
|
||||
provs = append(provs, p)
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
if len(provs) == 0 {
|
||||
return nil, errors.New("automation: SmartTransfer has no providers")
|
||||
}
|
||||
out := make([]Quote, 0, len(provs))
|
||||
for _, p := range provs {
|
||||
q, err := p.QuoteTransfer(ctx, req)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
q.Provider = p.Name()
|
||||
q.Provider_ = p
|
||||
out = append(out, q)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, errors.New("automation: no provider could service the request")
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if out[i].FeeBps != out[j].FeeBps {
|
||||
return out[i].FeeBps < out[j].FeeBps
|
||||
}
|
||||
// Tiebreaker — lexicographic provider name for determinism.
|
||||
return out[i].Provider < out[j].Provider
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Best returns the single cheapest quote. Convenience wrapper around Quote.
|
||||
func (s *SmartTransfer) Best(ctx context.Context, req TransferRequest) (Quote, error) {
|
||||
quotes, err := s.Quote(ctx, req)
|
||||
if err != nil {
|
||||
return Quote{}, err
|
||||
}
|
||||
return quotes[0], nil
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// OnChainProvider — direct same-chain transfer. Cost = configured base bps.
|
||||
// =============================================================================
|
||||
|
||||
// OnChainProvider quotes a direct same-chain transfer. It does not execute;
|
||||
// callers feed the resulting Quote into their existing sign + broadcast
|
||||
// path.
|
||||
type OnChainProvider struct {
|
||||
chain string
|
||||
bps int64
|
||||
eta time.Duration
|
||||
}
|
||||
|
||||
// NewOnChainProvider constructs a provider for one chain. bps is the
|
||||
// effective fee for routing this provider on this chain (gas + slippage
|
||||
// ballparked); operators tune via configuration.
|
||||
func NewOnChainProvider(chain string, bps int64, eta time.Duration) *OnChainProvider {
|
||||
return &OnChainProvider{chain: chain, bps: bps, eta: eta}
|
||||
}
|
||||
|
||||
func (p *OnChainProvider) Name() string { return "onchain:" + p.chain }
|
||||
|
||||
func (p *OnChainProvider) QuoteTransfer(_ context.Context, req TransferRequest) (Quote, error) {
|
||||
if req.Chain != "" && req.Chain != p.chain {
|
||||
return Quote{}, errors.New("onchain: wrong chain")
|
||||
}
|
||||
feeAbs := new(big.Int).Mul(req.Amount, big.NewInt(p.bps))
|
||||
feeAbs.Quo(feeAbs, big.NewInt(10000))
|
||||
return Quote{
|
||||
Provider: p.Name(),
|
||||
Route: p.chain + " direct",
|
||||
Asset: req.Asset,
|
||||
Amount: req.Amount.String(),
|
||||
Dest: req.To,
|
||||
Chain: p.chain,
|
||||
FeeBps: p.bps,
|
||||
FeeAbs: feeAbs.String(),
|
||||
ETA: p.eta,
|
||||
ValidUntil: time.Now().Add(60 * time.Second),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// BridgeProvider — cross-chain via Lux Bridge or any explicit route.
|
||||
// =============================================================================
|
||||
|
||||
// BridgeProvider quotes a cross-chain transfer. RouteName is used in the
|
||||
// quote's Route field for operator visibility.
|
||||
type BridgeProvider struct {
|
||||
name string
|
||||
bps int64
|
||||
srcChain string
|
||||
dstChain string
|
||||
eta time.Duration
|
||||
}
|
||||
|
||||
// NewBridgeProvider constructs a bridge provider. The (src,dst) pair is
|
||||
// part of the configuration — one provider instance per route direction.
|
||||
func NewBridgeProvider(name string, src, dst string, bps int64, eta time.Duration) *BridgeProvider {
|
||||
return &BridgeProvider{name: name, bps: bps, srcChain: src, dstChain: dst, eta: eta}
|
||||
}
|
||||
|
||||
func (p *BridgeProvider) Name() string { return "bridge:" + p.name }
|
||||
|
||||
func (p *BridgeProvider) QuoteTransfer(_ context.Context, req TransferRequest) (Quote, error) {
|
||||
if req.Chain != "" && req.Chain != p.dstChain {
|
||||
return Quote{}, errors.New("bridge: wrong destination chain")
|
||||
}
|
||||
feeAbs := new(big.Int).Mul(req.Amount, big.NewInt(p.bps))
|
||||
feeAbs.Quo(feeAbs, big.NewInt(10000))
|
||||
return Quote{
|
||||
Provider: p.Name(),
|
||||
Route: p.srcChain + " → " + p.dstChain + " via " + p.name,
|
||||
Asset: req.Asset,
|
||||
Amount: req.Amount.String(),
|
||||
Dest: req.To,
|
||||
Chain: p.dstChain,
|
||||
FeeBps: p.bps,
|
||||
FeeAbs: feeAbs.String(),
|
||||
ETA: p.eta,
|
||||
ValidUntil: time.Now().Add(60 * time.Second),
|
||||
}, nil
|
||||
}
|
||||
+158
-53
@@ -1,28 +1,72 @@
|
||||
import { Router, Route, Switch, Link, useLocation } from 'wouter'
|
||||
import { Router, Route, Switch, Link, useLocation, Redirect } from 'wouter'
|
||||
import { useHashLocation } from 'wouter/use-hash-location'
|
||||
import { auth } from './lib/api'
|
||||
import { Login } from './pages/Login'
|
||||
import { Dashboard } from './pages/Dashboard'
|
||||
import { Operations } from './pages/Operations'
|
||||
import { Sign } from './pages/Sign'
|
||||
import { Wallets } from './pages/Wallets'
|
||||
import { Vaults } from './pages/Vaults'
|
||||
import { Policies } from './pages/Policies'
|
||||
import { Whitelist } from './pages/Whitelist'
|
||||
import { Settlements } from './pages/Settlements'
|
||||
import { Bridge } from './pages/Bridge'
|
||||
import { Payments } from './pages/Payments'
|
||||
import { Audit } from './pages/Audit'
|
||||
import { Devices } from './pages/Devices'
|
||||
import { Webhooks } from './pages/Webhooks'
|
||||
import { APIKeys } from './pages/APIKeys'
|
||||
import { Users } from './pages/Users'
|
||||
import { Settings } from './pages/Settings'
|
||||
|
||||
const navItems = [
|
||||
{ href: '/', label: 'Dashboard' },
|
||||
{ href: '/wallets', label: 'Wallets' },
|
||||
{ href: '/vaults', label: 'Vaults' },
|
||||
{ href: '/policies', label: 'Policies' },
|
||||
{ href: '/settlements', label: 'Settlements' },
|
||||
{ href: '/bridge', label: 'Bridge' },
|
||||
{ href: '/payments', label: 'Payments' },
|
||||
{ href: '/audit', label: 'Audit' },
|
||||
{ href: '/settings', label: 'Settings' },
|
||||
] as const
|
||||
type NavItem = { href: string; label: string }
|
||||
type NavGroup = { title: string; items: NavItem[] }
|
||||
|
||||
function NavLink({ href, label }: { href: string; label: string }) {
|
||||
const groups: NavGroup[] = [
|
||||
{
|
||||
title: 'Treasury',
|
||||
items: [
|
||||
{ href: '/', label: 'Dashboard' },
|
||||
{ href: '/operations', label: 'Operations' },
|
||||
{ href: '/sign', label: 'New transaction' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Wallets',
|
||||
items: [
|
||||
{ href: '/wallets', label: 'Wallets' },
|
||||
{ href: '/vaults', label: 'Vaults' },
|
||||
{ href: '/whitelist', label: 'Allowlist' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Governance',
|
||||
items: [
|
||||
{ href: '/policies', label: 'Policies' },
|
||||
{ href: '/users', label: 'Users' },
|
||||
{ href: '/devices', label: 'Devices' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Pipeline',
|
||||
items: [
|
||||
{ href: '/settlements', label: 'Settlements' },
|
||||
{ href: '/bridge', label: 'Bridge' },
|
||||
{ href: '/payments', label: 'Payments' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'System',
|
||||
items: [
|
||||
{ href: '/audit', label: 'Audit log' },
|
||||
{ href: '/api-keys', label: 'API keys' },
|
||||
{ href: '/webhooks', label: 'Webhooks' },
|
||||
{ href: '/settings', label: 'Settings' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
function NavLink({ href, label }: NavItem) {
|
||||
const [location] = useLocation()
|
||||
const active = href === '/' ? location === '/' : location.startsWith(href)
|
||||
return (
|
||||
@@ -30,12 +74,12 @@ function NavLink({ href, label }: { href: string; label: string }) {
|
||||
href={href}
|
||||
style={{
|
||||
display: 'block',
|
||||
padding: '6px 12px',
|
||||
padding: '5px 12px',
|
||||
borderRadius: 6,
|
||||
color: active ? '#fff' : '#a3a3a3',
|
||||
background: active ? '#262626' : 'transparent',
|
||||
textDecoration: 'none',
|
||||
fontSize: 14,
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
@@ -43,47 +87,108 @@ function NavLink({ href, label }: { href: string; label: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
function Sidebar() {
|
||||
const me = auth.user()
|
||||
function logout() {
|
||||
auth.clear()
|
||||
window.location.hash = '#/login'
|
||||
}
|
||||
return (
|
||||
<aside
|
||||
style={{
|
||||
width: 220,
|
||||
borderRight: '1px solid #262626',
|
||||
padding: 16,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 12,
|
||||
flexShrink: 0,
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, fontSize: 16, color: '#fff' }}>MPC Admin</div>
|
||||
{me && (
|
||||
<div style={{ fontSize: 11, color: '#737373', marginTop: 2 }}>
|
||||
{me.email} · {me.role}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{groups.map((g) => (
|
||||
<nav key={g.title} style={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<div style={{
|
||||
fontSize: 10,
|
||||
color: '#525252',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.08em',
|
||||
padding: '4px 12px',
|
||||
}}>
|
||||
{g.title}
|
||||
</div>
|
||||
{g.items.map((n) => <NavLink key={n.href} {...n} />)}
|
||||
</nav>
|
||||
))}
|
||||
<button
|
||||
onClick={logout}
|
||||
style={{
|
||||
marginTop: 'auto',
|
||||
background: 'transparent',
|
||||
border: '1px solid #262626',
|
||||
borderRadius: 6,
|
||||
padding: '6px 12px',
|
||||
color: '#a3a3a3',
|
||||
fontSize: 12,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
function Protected({ children }: { children: React.ReactNode }) {
|
||||
const tok = auth.token()
|
||||
if (!tok) return <Redirect to="/login" />
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<Router hook={useHashLocation}>
|
||||
<div style={{ display: 'flex', height: '100vh', background: '#000', color: '#e5e5e5' }}>
|
||||
<aside
|
||||
style={{
|
||||
width: 200,
|
||||
borderRight: '1px solid #262626',
|
||||
padding: 16,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 2,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600, fontSize: 16, marginBottom: 16, color: '#fff' }}>
|
||||
MPC Admin
|
||||
</div>
|
||||
<nav style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{navItems.map((n) => (
|
||||
<NavLink key={n.href} href={n.href} label={n.label} />
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
<main style={{ flex: 1, overflow: 'auto', padding: 24 }}>
|
||||
<Switch>
|
||||
<Route path="/" component={Dashboard} />
|
||||
<Route path="/wallets" component={Wallets} />
|
||||
<Route path="/vaults" component={Vaults} />
|
||||
<Route path="/policies" component={Policies} />
|
||||
<Route path="/settlements" component={Settlements} />
|
||||
<Route path="/bridge" component={Bridge} />
|
||||
<Route path="/payments" component={Payments} />
|
||||
<Route path="/audit" component={Audit} />
|
||||
<Route path="/settings" component={Settings} />
|
||||
<Route>
|
||||
<div style={{ color: '#737373' }}>Not found</div>
|
||||
</Route>
|
||||
</Switch>
|
||||
</main>
|
||||
</div>
|
||||
<Switch>
|
||||
<Route path="/login" component={Login} />
|
||||
<Route>
|
||||
<Protected>
|
||||
<div style={{ display: 'flex', height: '100vh', background: '#000', color: '#e5e5e5' }}>
|
||||
<Sidebar />
|
||||
<main style={{ flex: 1, overflow: 'auto', padding: 24 }}>
|
||||
<Switch>
|
||||
<Route path="/" component={Dashboard} />
|
||||
<Route path="/operations" component={Operations} />
|
||||
<Route path="/sign" component={Sign} />
|
||||
<Route path="/wallets" component={Wallets} />
|
||||
<Route path="/vaults" component={Vaults} />
|
||||
<Route path="/whitelist" component={Whitelist} />
|
||||
<Route path="/policies" component={Policies} />
|
||||
<Route path="/users" component={Users} />
|
||||
<Route path="/devices" component={Devices} />
|
||||
<Route path="/settlements" component={Settlements} />
|
||||
<Route path="/bridge" component={Bridge} />
|
||||
<Route path="/payments" component={Payments} />
|
||||
<Route path="/audit" component={Audit} />
|
||||
<Route path="/api-keys" component={APIKeys} />
|
||||
<Route path="/webhooks" component={Webhooks} />
|
||||
<Route path="/settings" component={Settings} />
|
||||
<Route>
|
||||
<div style={{ color: '#737373' }}>Not found</div>
|
||||
</Route>
|
||||
</Switch>
|
||||
</main>
|
||||
</div>
|
||||
</Protected>
|
||||
</Route>
|
||||
</Switch>
|
||||
</Router>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -28,6 +28,30 @@ export function useTransactions(filters?: { status?: string; chain?: string }) {
|
||||
})
|
||||
}
|
||||
|
||||
export function useOperations(filters?: { status?: string; walletId?: string; page?: number; perPage?: number }) {
|
||||
return useQuery({
|
||||
queryKey: ['mpc', 'operations', filters],
|
||||
queryFn: () => api.listOperations(filters),
|
||||
refetchInterval: 5000,
|
||||
})
|
||||
}
|
||||
|
||||
export function useOperation(operationId: string) {
|
||||
return useQuery({
|
||||
queryKey: ['mpc', 'operation', operationId],
|
||||
queryFn: () => api.getOperation(operationId),
|
||||
enabled: !!operationId,
|
||||
})
|
||||
}
|
||||
|
||||
export function useSessions(walletId: string) {
|
||||
return useQuery({
|
||||
queryKey: ['mpc', 'sessions', walletId],
|
||||
queryFn: () => api.listSessions(walletId),
|
||||
enabled: !!walletId,
|
||||
})
|
||||
}
|
||||
|
||||
export function usePolicies() {
|
||||
return useQuery({ queryKey: ['mpc', 'policies'], queryFn: api.listPolicies })
|
||||
}
|
||||
@@ -62,3 +86,23 @@ export function useWalletBackups(walletId: string) {
|
||||
enabled: !!walletId,
|
||||
})
|
||||
}
|
||||
|
||||
export function useUsers() {
|
||||
return useQuery({ queryKey: ['mpc', 'users'], queryFn: api.listUsers })
|
||||
}
|
||||
|
||||
export function useAPIKeys() {
|
||||
return useQuery({ queryKey: ['mpc', 'api-keys'], queryFn: api.listAPIKeys })
|
||||
}
|
||||
|
||||
export function useWebhooks() {
|
||||
return useQuery({ queryKey: ['mpc', 'webhooks'], queryFn: api.listWebhooks })
|
||||
}
|
||||
|
||||
export function useWhitelist() {
|
||||
return useQuery({ queryKey: ['mpc', 'whitelist'], queryFn: api.listWhitelist })
|
||||
}
|
||||
|
||||
export function useWebAuthnCredentials() {
|
||||
return useQuery({ queryKey: ['mpc', 'webauthn'], queryFn: api.listWebAuthnCredentials })
|
||||
}
|
||||
|
||||
+298
-43
@@ -1,24 +1,48 @@
|
||||
// Typed API client for MPC backend — all paths relative to /v1/mpc
|
||||
// Typed API client for MPC backend.
|
||||
//
|
||||
// Two surfaces: the spec'd Liquidity MPC namespace at /v1/mpc/* and the
|
||||
// platform-internal /v1/* (auth, users, webhooks, api-keys, whitelist,
|
||||
// webauthn). Everything authenticates with the bearer JWT in localStorage.
|
||||
|
||||
const BASE = '/v1/mpc'
|
||||
const ROOT = '/v1'
|
||||
const MPC = '/v1/mpc'
|
||||
|
||||
export class APIError extends Error {
|
||||
constructor(public status: number, public body: { error: string }) {
|
||||
super(body.error)
|
||||
constructor(public status: number, public body: { error?: string }) {
|
||||
super(body.error ?? `HTTP ${status}`)
|
||||
this.name = 'APIError'
|
||||
}
|
||||
}
|
||||
|
||||
function token(): string | null {
|
||||
return localStorage.getItem('mpc_token')
|
||||
const TOKEN_KEY = 'mpc_token'
|
||||
const REFRESH_KEY = 'mpc_refresh'
|
||||
const USER_KEY = 'mpc_user'
|
||||
|
||||
export const auth = {
|
||||
token: (): string | null => localStorage.getItem(TOKEN_KEY),
|
||||
refresh: (): string | null => localStorage.getItem(REFRESH_KEY),
|
||||
user: (): { id: string; org_id: string; role: string; email: string } | null => {
|
||||
const raw = localStorage.getItem(USER_KEY)
|
||||
return raw ? JSON.parse(raw) : null
|
||||
},
|
||||
set: (token: string, refresh: string, user: { id: string; org_id: string; role: string; email: string }) => {
|
||||
localStorage.setItem(TOKEN_KEY, token)
|
||||
localStorage.setItem(REFRESH_KEY, refresh)
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(user))
|
||||
},
|
||||
clear: () => {
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
localStorage.removeItem(REFRESH_KEY)
|
||||
localStorage.removeItem(USER_KEY)
|
||||
},
|
||||
}
|
||||
|
||||
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
async function request<T>(method: string, fullPath: string, body?: unknown): Promise<T> {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||
const t = token()
|
||||
const t = auth.token()
|
||||
if (t) headers['Authorization'] = `Bearer ${t}`
|
||||
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
const res = await fetch(fullPath, {
|
||||
method,
|
||||
headers,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
@@ -26,17 +50,26 @@ async function request<T>(method: string, path: string, body?: unknown): Promise
|
||||
|
||||
if (res.status === 204) return undefined as T
|
||||
|
||||
const data = await res.json()
|
||||
// 401: token dead, force re-login
|
||||
if (res.status === 401 && fullPath !== `${ROOT}/auth/login`) {
|
||||
auth.clear()
|
||||
if (typeof window !== 'undefined') window.location.hash = '#/login'
|
||||
}
|
||||
|
||||
const text = await res.text()
|
||||
const data = text ? JSON.parse(text) : {}
|
||||
if (!res.ok) throw new APIError(res.status, data)
|
||||
return data as T
|
||||
}
|
||||
|
||||
function get<T>(path: string): Promise<T> { return request<T>('GET', path) }
|
||||
function post<T>(path: string, body?: unknown): Promise<T> { return request<T>('POST', path, body) }
|
||||
function patch<T>(path: string, body?: unknown): Promise<T> { return request<T>('PATCH', path, body) }
|
||||
function del<T>(path: string): Promise<T> { return request<T>('DELETE', path) }
|
||||
const get = <T>(p: string) => request<T>('GET', p)
|
||||
const post = <T>(p: string, b?: unknown) => request<T>('POST', p, b)
|
||||
const patch = <T>(p: string, b?: unknown) => request<T>('PATCH', p, b)
|
||||
const del = <T>(p: string) => request<T>('DELETE', p)
|
||||
|
||||
// --- Types (matching Go backend models) ---
|
||||
// =================================================================
|
||||
// Types — matching Go backend models
|
||||
// =================================================================
|
||||
|
||||
export interface ClusterStatus {
|
||||
node_id: string
|
||||
@@ -98,6 +131,33 @@ export interface Transaction {
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface OperationApproval {
|
||||
approverId: string
|
||||
approvedAt: string
|
||||
notes?: string
|
||||
}
|
||||
|
||||
export interface Operation {
|
||||
operationId: string
|
||||
walletId: string
|
||||
kind: string
|
||||
payload?: Record<string, unknown>
|
||||
status: string
|
||||
approvers?: string[]
|
||||
approvals?: OperationApproval[]
|
||||
rejectionReason?: string
|
||||
result?: Record<string, string>
|
||||
createdAt: string
|
||||
completedAt?: string
|
||||
}
|
||||
|
||||
export interface OperationsPage {
|
||||
items: Operation[]
|
||||
page: number
|
||||
perPage: number
|
||||
totalItems: number
|
||||
}
|
||||
|
||||
export interface Policy {
|
||||
id: string
|
||||
org_id: string
|
||||
@@ -173,35 +233,151 @@ export interface BridgeConfig {
|
||||
withdrawalsEnabled: boolean
|
||||
}
|
||||
|
||||
// --- API methods ---
|
||||
export interface User {
|
||||
id: string
|
||||
createdAt: string
|
||||
orgId: string
|
||||
email: string
|
||||
role: string
|
||||
}
|
||||
|
||||
export interface APIKey {
|
||||
id: string
|
||||
name: string
|
||||
keyPrefix: string
|
||||
permissions: string[]
|
||||
expiresAt?: string | null
|
||||
lastUsedAt?: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface APIKeyCreated {
|
||||
id: string
|
||||
name: string
|
||||
key: string
|
||||
key_prefix: string
|
||||
permissions: string[]
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface Webhook {
|
||||
id: string
|
||||
org_id: string
|
||||
url: string
|
||||
secret: string
|
||||
events: string[]
|
||||
enabled: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface WhitelistEntry {
|
||||
id: string
|
||||
org_id: string
|
||||
vault_id?: string | null
|
||||
address: string
|
||||
chain: string
|
||||
label?: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface WebAuthnCredential {
|
||||
id: string
|
||||
device_name?: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
sessionId: string
|
||||
walletId: string
|
||||
grantedTo?: string
|
||||
scopes?: string[]
|
||||
valueLimit?: string
|
||||
operationLimit?: number
|
||||
status: string
|
||||
createdAt: string
|
||||
expiresAt: string
|
||||
revokedAt?: string
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
user_id: string
|
||||
org_id: string
|
||||
role: string
|
||||
email?: string
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
mfa_required?: boolean
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// API methods
|
||||
// =================================================================
|
||||
|
||||
export const api = {
|
||||
// Status
|
||||
getStatus: () => get<ClusterStatus>('/status'),
|
||||
getInfo: () => get<InfoResponse>('/info'),
|
||||
// -------- Auth (no /mpc prefix; raw v1) --------
|
||||
login: (email: string, password: string, mfa_code?: string) =>
|
||||
post<LoginResponse>(`${ROOT}/auth/login`, { email, password, mfa_code }),
|
||||
refresh: (refresh_token: string) =>
|
||||
post<{ access_token: string; refresh_token: string }>(`${ROOT}/auth/refresh`, { refresh_token }),
|
||||
register: (email: string, password: string, org_name: string) =>
|
||||
post<LoginResponse>(`${ROOT}/auth/register`, { email, password, org_name }),
|
||||
|
||||
// Vaults
|
||||
listVaults: () => get<Vault[]>('/vaults'),
|
||||
createVault: (req: { name: string; description?: string }) => post<Vault>('/vaults', req),
|
||||
deleteVault: (id: string) => del(`/vaults/${id}`),
|
||||
// -------- Status --------
|
||||
getStatus: () => get<ClusterStatus>(`${MPC}/status`),
|
||||
getInfo: () => get<InfoResponse>(`${MPC}/info`),
|
||||
|
||||
// Wallets
|
||||
listWallets: (vaultId: string) => get<Wallet[]>(`/vaults/${vaultId}/wallets`),
|
||||
// -------- Vaults --------
|
||||
listVaults: () => get<Vault[]>(`${MPC}/vaults`),
|
||||
createVault: (req: { name: string; description?: string }) =>
|
||||
post<Vault>(`${MPC}/vaults`, req),
|
||||
deleteVault: (id: string) => del<void>(`${MPC}/vaults/${id}`),
|
||||
|
||||
// -------- Wallets --------
|
||||
listWallets: (vaultId: string) => get<Wallet[]>(`${MPC}/vaults/${vaultId}/wallets`),
|
||||
createWallet: (vaultId: string, req?: { name?: string; key_type?: string; protocol?: string }) =>
|
||||
post<Wallet>(`/vaults/${vaultId}/wallets`, req),
|
||||
getWalletBackups: (walletId: string) => get<WalletBackup[]>(`/wallets/${walletId}/backup`),
|
||||
post<Wallet>(`${MPC}/vaults/${vaultId}/wallets`, req),
|
||||
getWalletBackups: (walletId: string) =>
|
||||
get<WalletBackup[]>(`${MPC}/wallets/${walletId}/backup`),
|
||||
|
||||
// Transactions
|
||||
// -------- Transactions / Operations --------
|
||||
listTransactions: (filters?: { status?: string; chain?: string }) => {
|
||||
const params = new URLSearchParams()
|
||||
if (filters?.status) params.set('status', filters.status)
|
||||
if (filters?.chain) params.set('chain', filters.chain)
|
||||
const qs = params.toString()
|
||||
return get<Transaction[]>(`/transactions${qs ? `?${qs}` : ''}`)
|
||||
return get<Transaction[]>(`${MPC}/transactions${qs ? `?${qs}` : ''}`)
|
||||
},
|
||||
|
||||
// Policies
|
||||
listPolicies: () => get<Policy[]>('/policies'),
|
||||
listOperations: (filters?: { status?: string; walletId?: string; page?: number; perPage?: number }) => {
|
||||
const params = new URLSearchParams()
|
||||
if (filters?.status) params.set('status', filters.status)
|
||||
if (filters?.walletId) params.set('walletId', filters.walletId)
|
||||
if (filters?.page) params.set('page', String(filters.page))
|
||||
if (filters?.perPage) params.set('perPage', String(filters.perPage))
|
||||
const qs = params.toString()
|
||||
return get<OperationsPage>(`${MPC}/operations${qs ? `?${qs}` : ''}`)
|
||||
},
|
||||
getOperation: (id: string) => get<Operation>(`${MPC}/operations/${id}`),
|
||||
approveOperation: (id: string, notes?: string) =>
|
||||
post<Operation>(`${MPC}/operations/${id}/approve`, { notes: notes ?? '' }),
|
||||
rejectOperation: (id: string, reason: string) =>
|
||||
post<Operation>(`${MPC}/operations/${id}/reject`, { reason }),
|
||||
|
||||
// -------- Sessions (per-wallet signing grants) --------
|
||||
listSessions: (walletId: string) =>
|
||||
get<{ items: Session[] }>(`${MPC}/wallets/${walletId}/sessions`),
|
||||
createSession: (walletId: string, req: {
|
||||
grantedTo?: string
|
||||
scopes: string[]
|
||||
valueLimit?: string
|
||||
operationLimit?: number
|
||||
expiresAt: string
|
||||
}) => post<Session>(`${MPC}/wallets/${walletId}/sessions`, req),
|
||||
revokeSession: (walletId: string, sessionId: string) =>
|
||||
del<void>(`${MPC}/wallets/${walletId}/sessions/${sessionId}`),
|
||||
|
||||
// -------- Policies --------
|
||||
listPolicies: () => get<Policy[]>(`${MPC}/policies`),
|
||||
createPolicy: (req: {
|
||||
name: string
|
||||
action: string
|
||||
@@ -209,25 +385,104 @@ export const api = {
|
||||
conditions: Record<string, unknown>
|
||||
required_approvers: number
|
||||
approver_roles: string[]
|
||||
}) => post<Policy>('/policies', req),
|
||||
updatePolicy: (id: string, req: { enabled?: boolean }) => patch<Policy>(`/policies/${id}`, req),
|
||||
deletePolicy: (id: string) => del(`/policies/${id}`),
|
||||
}) => post<Policy>(`${MPC}/policies`, req),
|
||||
updatePolicy: (id: string, req: { enabled?: boolean }) =>
|
||||
patch<Policy>(`${MPC}/policies/${id}`, req),
|
||||
deletePolicy: (id: string) => del<void>(`${MPC}/policies/${id}`),
|
||||
|
||||
// Settlements
|
||||
// -------- Settlements --------
|
||||
listSettlements: (filters?: { status?: string }) => {
|
||||
const params = new URLSearchParams()
|
||||
if (filters?.status) params.set('status', filters.status)
|
||||
const qs = params.toString()
|
||||
return get<Settlement[]>(`/settlements${qs ? `?${qs}` : ''}`)
|
||||
return get<Settlement[]>(`${MPC}/settlements${qs ? `?${qs}` : ''}`)
|
||||
},
|
||||
|
||||
// Bridge
|
||||
getBridgeConfig: () => get<BridgeConfig>('/bridge/config'),
|
||||
listBridgeNetworks: () => get<BridgeNetwork[]>('/bridge/networks'),
|
||||
// -------- Bridge --------
|
||||
getBridgeConfig: () => get<BridgeConfig>(`${MPC}/bridge/config`),
|
||||
listBridgeNetworks: () => get<BridgeNetwork[]>(`${MPC}/bridge/networks`),
|
||||
|
||||
// Payments
|
||||
listPaymentRequests: () => get<PaymentRequest[]>('/payment-requests'),
|
||||
// -------- Payments --------
|
||||
listPaymentRequests: () => get<PaymentRequest[]>(`${MPC}/payment-requests`),
|
||||
|
||||
// Audit
|
||||
listAudit: () => get<AuditEntry[]>('/audit'),
|
||||
// -------- Audit --------
|
||||
listAudit: () => get<AuditEntry[]>(`${MPC}/audit`),
|
||||
|
||||
// -------- Users --------
|
||||
listUsers: () => get<User[]>(`${ROOT}/users`),
|
||||
inviteUser: (req: { email: string; password: string; role?: string }) =>
|
||||
post<User>(`${ROOT}/users`, req),
|
||||
updateUser: (id: string, req: { role?: string }) =>
|
||||
patch<User>(`${ROOT}/users/${id}`, req),
|
||||
deleteUser: (id: string) => del<void>(`${ROOT}/users/${id}`),
|
||||
|
||||
// -------- API Keys --------
|
||||
listAPIKeys: () => get<APIKey[]>(`${ROOT}/api-keys`),
|
||||
createAPIKey: (req: { name: string; permissions?: string[] }) =>
|
||||
post<APIKeyCreated>(`${ROOT}/api-keys`, req),
|
||||
deleteAPIKey: (id: string) => del<void>(`${ROOT}/api-keys/${id}`),
|
||||
|
||||
// -------- Webhooks --------
|
||||
listWebhooks: () => get<Webhook[]>(`${ROOT}/webhooks`),
|
||||
createWebhook: (req: { url: string; events: string[]; secret: string }) =>
|
||||
post<Webhook>(`${ROOT}/webhooks`, req),
|
||||
updateWebhook: (id: string, req: { url?: string; events?: string[]; enabled?: boolean }) =>
|
||||
patch<Webhook>(`${ROOT}/webhooks/${id}`, req),
|
||||
deleteWebhook: (id: string) => del<void>(`${ROOT}/webhooks/${id}`),
|
||||
testWebhook: (id: string) =>
|
||||
post<{ status: string }>(`${ROOT}/webhooks/${id}/test`),
|
||||
|
||||
// -------- Whitelist (allowlist for destinations) --------
|
||||
listWhitelist: () => get<WhitelistEntry[]>(`${ROOT}/whitelist`),
|
||||
addWhitelist: (req: { address: string; chain: string; label?: string; vault_id?: string }) =>
|
||||
post<WhitelistEntry>(`${ROOT}/whitelist`, req),
|
||||
deleteWhitelist: (id: string) => del<void>(`${ROOT}/whitelist/${id}`),
|
||||
|
||||
// -------- WebAuthn --------
|
||||
listWebAuthnCredentials: () => get<WebAuthnCredential[]>(`${ROOT}/webauthn/credentials`),
|
||||
deleteWebAuthnCredential: (id: string) => del<void>(`${ROOT}/webauthn/credentials/${id}`),
|
||||
webAuthnRegisterBegin: () =>
|
||||
post<WebAuthnRegisterOptions>(`${ROOT}/webauthn/register/begin`),
|
||||
webAuthnRegisterComplete: (body: WebAuthnRegisterComplete) =>
|
||||
post<{ status: string; credential_id: string }>(`${ROOT}/webauthn/register/complete`, body),
|
||||
webAuthnVerify: (body: WebAuthnVerify) =>
|
||||
post<{ status: string; biometric: boolean; signing_started: boolean; approvals: number; required: number }>(
|
||||
`${ROOT}/webauthn/verify`, body,
|
||||
),
|
||||
}
|
||||
|
||||
// WebAuthn ceremony types — server-shaped, not browser-shaped.
|
||||
export interface WebAuthnRegisterOptions {
|
||||
challenge: string
|
||||
rp: { id: string; name: string }
|
||||
user: { id: string; name: string; displayName: string }
|
||||
pubKeyCredParams: { type: string; alg: number }[]
|
||||
timeout: number
|
||||
attestation: string
|
||||
authenticatorSelection: {
|
||||
authenticatorAttachment: string
|
||||
userVerification: string
|
||||
residentKey: string
|
||||
}
|
||||
credential_id: string
|
||||
}
|
||||
|
||||
export interface WebAuthnRegisterComplete {
|
||||
credential_id: string
|
||||
id: string
|
||||
rawId: string
|
||||
type: string
|
||||
response: {
|
||||
attestationObject: string
|
||||
clientDataJSON: string
|
||||
}
|
||||
device_name: string
|
||||
}
|
||||
|
||||
export interface WebAuthnVerify {
|
||||
transaction_id: string
|
||||
credential_id: string
|
||||
authenticator_data: string
|
||||
client_data_json: string
|
||||
signature: string
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
// Shared inline-style tokens. Single source of truth for the dashboard.
|
||||
import type { CSSProperties } from 'react'
|
||||
|
||||
export const card: CSSProperties = {
|
||||
background: '#0a0a0a',
|
||||
border: '1px solid #262626',
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
}
|
||||
|
||||
export const label: CSSProperties = {
|
||||
fontSize: 12,
|
||||
color: '#737373',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
}
|
||||
|
||||
export const input: CSSProperties = {
|
||||
width: '100%',
|
||||
background: '#0a0a0a',
|
||||
border: '1px solid #262626',
|
||||
borderRadius: 6,
|
||||
padding: '6px 12px',
|
||||
color: '#e5e5e5',
|
||||
fontSize: 13,
|
||||
}
|
||||
|
||||
export const btn: CSSProperties = {
|
||||
padding: '6px 16px',
|
||||
borderRadius: 6,
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
border: 'none',
|
||||
background: '#fff',
|
||||
color: '#000',
|
||||
}
|
||||
|
||||
export const btnGhost: CSSProperties = {
|
||||
...btn,
|
||||
background: 'transparent',
|
||||
color: '#a3a3a3',
|
||||
border: '1px solid #262626',
|
||||
}
|
||||
|
||||
export const btnDanger: CSSProperties = {
|
||||
...btn,
|
||||
background: '#ef4444',
|
||||
color: '#fff',
|
||||
}
|
||||
|
||||
export const table: CSSProperties = {
|
||||
width: '100%',
|
||||
borderCollapse: 'collapse',
|
||||
fontSize: 13,
|
||||
}
|
||||
|
||||
export const th: CSSProperties = { padding: '8px 12px', textAlign: 'left' }
|
||||
export const td: CSSProperties = { padding: '8px 12px' }
|
||||
|
||||
export const tHead: CSSProperties = {
|
||||
borderBottom: '1px solid #262626',
|
||||
color: '#737373',
|
||||
}
|
||||
|
||||
export const tRow: CSSProperties = { borderBottom: '1px solid #171717' }
|
||||
|
||||
export const mono: CSSProperties = { fontFamily: 'monospace' }
|
||||
|
||||
export const h1: CSSProperties = { fontSize: 20, fontWeight: 600, color: '#fff' }
|
||||
export const sub: CSSProperties = { fontSize: 13, color: '#737373', marginTop: 4 }
|
||||
|
||||
export const errorText: CSSProperties = { color: '#ef4444', fontSize: 13 }
|
||||
|
||||
export function disabledStyle(disabled: boolean): CSSProperties {
|
||||
return disabled ? { opacity: 0.5, cursor: 'not-allowed' } : {}
|
||||
}
|
||||
|
||||
export function shorten(s: string, head = 8, tail = 6): string {
|
||||
if (!s) return '--'
|
||||
if (s.length <= head + tail + 3) return s
|
||||
return `${s.slice(0, head)}...${s.slice(-tail)}`
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// Browser <-> server WebAuthn glue. Translates the server's base64url-encoded
|
||||
// PublicKeyCredentialCreationOptions into the browser's required ArrayBuffer
|
||||
// shapes, runs navigator.credentials.create(), and serializes the result back
|
||||
// to base64url for the /webauthn/register/complete endpoint.
|
||||
|
||||
import type { WebAuthnRegisterOptions, WebAuthnRegisterComplete } from './api'
|
||||
|
||||
function b64urlToBuffer(b64: string): ArrayBuffer {
|
||||
// Backend uses base64.URLEncoding (with padding). atob accepts standard alphabet,
|
||||
// so map -/_ to +// and let atob ignore padding.
|
||||
const std = b64.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const bin = atob(std)
|
||||
const buf = new ArrayBuffer(bin.length)
|
||||
const view = new Uint8Array(buf)
|
||||
for (let i = 0; i < bin.length; i++) view[i] = bin.charCodeAt(i)
|
||||
return buf
|
||||
}
|
||||
|
||||
function bytesToB64url(bytes: ArrayBuffer | Uint8Array): string {
|
||||
const u8 = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes)
|
||||
let bin = ''
|
||||
for (let i = 0; i < u8.length; i++) bin += String.fromCharCode(u8[i])
|
||||
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_')
|
||||
}
|
||||
|
||||
export async function runWebAuthnRegister(
|
||||
options: WebAuthnRegisterOptions,
|
||||
deviceName: string,
|
||||
): Promise<WebAuthnRegisterComplete> {
|
||||
if (!('credentials' in navigator)) {
|
||||
throw new Error('WebAuthn unsupported in this browser')
|
||||
}
|
||||
|
||||
const publicKey: PublicKeyCredentialCreationOptions = {
|
||||
challenge: b64urlToBuffer(options.challenge),
|
||||
rp: { id: options.rp.id, name: options.rp.name },
|
||||
user: {
|
||||
id: b64urlToBuffer(options.user.id),
|
||||
name: options.user.name,
|
||||
displayName: options.user.displayName,
|
||||
},
|
||||
pubKeyCredParams: options.pubKeyCredParams.map((p) => ({
|
||||
type: p.type as PublicKeyCredentialType,
|
||||
alg: p.alg,
|
||||
})),
|
||||
timeout: options.timeout,
|
||||
attestation: options.attestation as AttestationConveyancePreference,
|
||||
authenticatorSelection: {
|
||||
authenticatorAttachment: options.authenticatorSelection.authenticatorAttachment as AuthenticatorAttachment,
|
||||
userVerification: options.authenticatorSelection.userVerification as UserVerificationRequirement,
|
||||
residentKey: options.authenticatorSelection.residentKey as ResidentKeyRequirement,
|
||||
},
|
||||
}
|
||||
|
||||
const cred = (await navigator.credentials.create({ publicKey })) as PublicKeyCredential | null
|
||||
if (!cred) throw new Error('No credential returned by authenticator')
|
||||
|
||||
const att = cred.response as AuthenticatorAttestationResponse
|
||||
return {
|
||||
credential_id: options.credential_id,
|
||||
id: cred.id,
|
||||
rawId: bytesToB64url(cred.rawId),
|
||||
type: cred.type,
|
||||
response: {
|
||||
attestationObject: bytesToB64url(att.attestationObject),
|
||||
clientDataJSON: bytesToB64url(att.clientDataJSON),
|
||||
},
|
||||
device_name: deviceName,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useAPIKeys } from '../hooks/use-mpc'
|
||||
import { api } from '../lib/api'
|
||||
import {
|
||||
card, label, btn, btnGhost, btnDanger,
|
||||
table, th, td, tHead, tRow, mono, h1, sub, errorText, disabledStyle,
|
||||
} from '../lib/styles'
|
||||
|
||||
const presetPermissions = [
|
||||
'mpc:sign',
|
||||
'mpc:settlement:sign',
|
||||
'mpc:operations:approve',
|
||||
'mpc:wallet:sweep',
|
||||
'mpc:policy:write',
|
||||
'mpc:transactions:create',
|
||||
'mpc:subscriptions:write',
|
||||
'mpc:payment-requests:write',
|
||||
'mpc:smart-wallets:write',
|
||||
'mpc:trade:write',
|
||||
'mpc.treasury.admin',
|
||||
'mpc.treasury.sign',
|
||||
'sign',
|
||||
'trade:submit',
|
||||
]
|
||||
|
||||
export function APIKeys() {
|
||||
const qc = useQueryClient()
|
||||
const { data: keys, error } = useAPIKeys()
|
||||
const [name, setName] = useState('')
|
||||
const [perms, setPerms] = useState<Set<string>>(new Set())
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [err, setErr] = useState('')
|
||||
const [revealed, setRevealed] = useState<{ name: string; key: string } | null>(null)
|
||||
|
||||
function toggle(p: string) {
|
||||
const n = new Set(perms)
|
||||
if (n.has(p)) n.delete(p); else n.add(p)
|
||||
setPerms(n)
|
||||
}
|
||||
|
||||
async function create(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!name.trim()) return
|
||||
setBusy(true); setErr('')
|
||||
try {
|
||||
const r = await api.createAPIKey({
|
||||
name: name.trim(),
|
||||
permissions: Array.from(perms),
|
||||
})
|
||||
setRevealed({ name: r.name, key: r.key })
|
||||
setName(''); setPerms(new Set())
|
||||
qc.invalidateQueries({ queryKey: ['mpc', 'api-keys'] })
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : 'Create failed')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(id: string) {
|
||||
try {
|
||||
await api.deleteAPIKey(id)
|
||||
qc.invalidateQueries({ queryKey: ['mpc', 'api-keys'] })
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : 'Delete failed')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||
<div>
|
||||
<h1 style={h1}>API Keys</h1>
|
||||
<p style={sub}>
|
||||
Service-account credentials. Keys are shown once at creation; capture them then.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{err && <div style={errorText}>{err}</div>}
|
||||
|
||||
{revealed && (
|
||||
<div style={{ ...card, borderColor: '#22c55e' }}>
|
||||
<div style={{ ...label, color: '#22c55e' }}>Key created — copy now</div>
|
||||
<div style={{ marginTop: 4, fontSize: 13 }}>{revealed.name}</div>
|
||||
<div style={{ marginTop: 8, padding: 8, background: '#000', borderRadius: 4, ...mono, fontSize: 12, wordBreak: 'break-all' }}>
|
||||
{revealed.key}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setRevealed(null)}
|
||||
style={{ ...btnGhost, marginTop: 12, padding: '4px 12px' }}
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={create} style={card}>
|
||||
<div style={{ ...label, marginBottom: 8 }}>New API key</div>
|
||||
<input
|
||||
placeholder="Key name (e.g. ats-service-account)"
|
||||
required
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
style={{
|
||||
background: '#0a0a0a', border: '1px solid #262626', borderRadius: 6,
|
||||
padding: '6px 12px', color: '#e5e5e5', fontSize: 13, width: '100%',
|
||||
}}
|
||||
/>
|
||||
<div style={{ ...label, marginTop: 12 }}>Permissions (must enumerate; wildcard `*` is rejected)</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 8 }}>
|
||||
{presetPermissions.map((p) => {
|
||||
const on = perms.has(p)
|
||||
return (
|
||||
<button
|
||||
key={p}
|
||||
type="button"
|
||||
onClick={() => toggle(p)}
|
||||
style={{
|
||||
...btnGhost,
|
||||
padding: '3px 10px', fontSize: 11, ...mono,
|
||||
...(on ? { background: '#22c55e20', color: '#22c55e', borderColor: '#22c55e' } : {}),
|
||||
}}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || !name.trim() || perms.size === 0}
|
||||
style={{ ...btn, marginTop: 12, ...disabledStyle(busy || !name.trim() || perms.size === 0) }}
|
||||
>
|
||||
{busy ? 'Creating...' : 'Create key'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{error && <div style={errorText}>{(error as Error).message}</div>}
|
||||
|
||||
<table style={table}>
|
||||
<thead>
|
||||
<tr style={tHead}>
|
||||
<th style={th}>Name</th>
|
||||
<th style={th}>Prefix</th>
|
||||
<th style={th}>Permissions</th>
|
||||
<th style={th}>Last used</th>
|
||||
<th style={th}>Created</th>
|
||||
<th style={{ ...th, textAlign: 'right' }}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(keys ?? []).map((k) => (
|
||||
<tr key={k.id} style={tRow}>
|
||||
<td style={td}>{k.name}</td>
|
||||
<td style={{ ...td, ...mono, fontSize: 11 }}>{k.keyPrefix}…</td>
|
||||
<td style={{ ...td, fontSize: 11, color: '#a3a3a3' }}>
|
||||
{k.permissions.length > 0 ? k.permissions.join(', ') : <span style={{ color: '#525252' }}>none</span>}
|
||||
</td>
|
||||
<td style={{ ...td, color: '#737373' }}>{k.lastUsedAt ? new Date(k.lastUsedAt).toLocaleString() : '--'}</td>
|
||||
<td style={{ ...td, color: '#737373' }}>{new Date(k.createdAt).toLocaleString()}</td>
|
||||
<td style={{ ...td, textAlign: 'right' }}>
|
||||
<button
|
||||
onClick={() => remove(k.id)}
|
||||
style={{ ...btnDanger, padding: '4px 12px', background: 'transparent', color: '#ef4444', border: '1px solid #ef4444' }}
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{(keys ?? []).length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} style={{ ...td, color: '#525252' }}>
|
||||
No API keys.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useState } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useWebAuthnCredentials } from '../hooks/use-mpc'
|
||||
import { api } from '../lib/api'
|
||||
import { runWebAuthnRegister } from '../lib/webauthn'
|
||||
import {
|
||||
card, label, btn, btnGhost,
|
||||
table, th, td, tHead, tRow, mono, h1, sub, errorText, disabledStyle, shorten,
|
||||
} from '../lib/styles'
|
||||
|
||||
export function Devices() {
|
||||
const qc = useQueryClient()
|
||||
const { data: creds, error } = useWebAuthnCredentials()
|
||||
const [deviceName, setDeviceName] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [err, setErr] = useState('')
|
||||
const [info, setInfo] = useState('')
|
||||
|
||||
async function pair() {
|
||||
if (!deviceName.trim()) return
|
||||
setBusy(true); setErr(''); setInfo('')
|
||||
try {
|
||||
const opts = await api.webAuthnRegisterBegin()
|
||||
const completed = await runWebAuthnRegister(opts, deviceName.trim())
|
||||
const r = await api.webAuthnRegisterComplete(completed)
|
||||
setInfo(`Paired ${r.credential_id}`)
|
||||
setDeviceName('')
|
||||
qc.invalidateQueries({ queryKey: ['mpc', 'webauthn'] })
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : 'Pairing failed')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function revoke(id: string) {
|
||||
setBusy(true); setErr('')
|
||||
try {
|
||||
await api.deleteWebAuthnCredential(id)
|
||||
qc.invalidateQueries({ queryKey: ['mpc', 'webauthn'] })
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : 'Revoke failed')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||
<div>
|
||||
<h1 style={h1}>Devices</h1>
|
||||
<p style={sub}>
|
||||
WebAuthn authenticators for biometric and hardware-key approval. Touch ID, Face ID, Windows Hello, YubiKey.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{err && <div style={errorText}>{err}</div>}
|
||||
{info && <div style={{ color: '#22c55e', fontSize: 13 }}>{info}</div>}
|
||||
|
||||
<div style={card}>
|
||||
<div style={{ ...label, marginBottom: 8 }}>Pair new device</div>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<input
|
||||
placeholder="Device label (e.g. MacBook Touch ID)"
|
||||
value={deviceName}
|
||||
onChange={(e) => setDeviceName(e.target.value)}
|
||||
style={{
|
||||
flex: 1, minWidth: 240,
|
||||
background: '#0a0a0a', border: '1px solid #262626', borderRadius: 6,
|
||||
padding: '6px 12px', color: '#e5e5e5', fontSize: 13,
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={pair}
|
||||
disabled={busy || !deviceName.trim()}
|
||||
style={{ ...btn, ...disabledStyle(busy || !deviceName.trim()) }}
|
||||
>
|
||||
{busy ? 'Authenticating...' : 'Pair'}
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: '#525252', marginTop: 8 }}>
|
||||
The browser will prompt your platform authenticator. Ensure user verification is available.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div style={errorText}>{(error as Error).message}</div>}
|
||||
|
||||
<table style={table}>
|
||||
<thead>
|
||||
<tr style={tHead}>
|
||||
<th style={th}>Device</th>
|
||||
<th style={th}>Credential ID</th>
|
||||
<th style={th}>Created</th>
|
||||
<th style={{ ...th, textAlign: 'right' }}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(creds ?? []).map((c) => (
|
||||
<tr key={c.id} style={tRow}>
|
||||
<td style={td}>{c.device_name ?? '--'}</td>
|
||||
<td style={{ ...td, ...mono, fontSize: 11 }}>{shorten(c.id, 10, 6)}</td>
|
||||
<td style={{ ...td, color: '#737373' }}>{new Date(c.created_at).toLocaleString()}</td>
|
||||
<td style={{ ...td, textAlign: 'right' }}>
|
||||
<button
|
||||
onClick={() => revoke(c.id)}
|
||||
disabled={busy}
|
||||
style={{ ...btnGhost, padding: '4px 12px', ...disabledStyle(busy) }}
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{(creds ?? []).length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={4} style={{ ...td, color: '#525252' }}>
|
||||
No paired devices.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { useLocation } from 'wouter'
|
||||
import { api, auth } from '../lib/api'
|
||||
import { card, input, btn, label, h1, sub, errorText, disabledStyle } from '../lib/styles'
|
||||
|
||||
export function Login() {
|
||||
const [, navigate] = useLocation()
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [mfa, setMfa] = useState('')
|
||||
const [mfaRequired, setMfaRequired] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [err, setErr] = useState('')
|
||||
|
||||
async function onSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setBusy(true)
|
||||
setErr('')
|
||||
try {
|
||||
const r = await api.login(email, password, mfa || undefined)
|
||||
if (r.mfa_required) {
|
||||
setMfaRequired(true)
|
||||
return
|
||||
}
|
||||
auth.set(r.access_token, r.refresh_token, {
|
||||
id: r.user_id,
|
||||
org_id: r.org_id,
|
||||
role: r.role,
|
||||
email,
|
||||
})
|
||||
navigate('/')
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : 'Login failed')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: '#000',
|
||||
color: '#e5e5e5',
|
||||
padding: 24,
|
||||
}}
|
||||
>
|
||||
<form onSubmit={onSubmit} style={{ ...card, width: 360 }}>
|
||||
<div style={{ ...h1, marginBottom: 4 }}>MPC Admin</div>
|
||||
<div style={sub}>Sign in to manage threshold wallets, policies, and operations.</div>
|
||||
|
||||
<div style={{ marginTop: 24, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div>
|
||||
<div style={label}>Email</div>
|
||||
<input
|
||||
style={{ ...input, marginTop: 4 }}
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={mfaRequired}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div style={label}>Password</div>
|
||||
<input
|
||||
style={{ ...input, marginTop: 4 }}
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={mfaRequired}
|
||||
/>
|
||||
</div>
|
||||
{mfaRequired && (
|
||||
<div>
|
||||
<div style={label}>MFA Code</div>
|
||||
<input
|
||||
style={{ ...input, marginTop: 4, fontFamily: 'monospace' }}
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
maxLength={6}
|
||||
required
|
||||
autoFocus
|
||||
value={mfa}
|
||||
onChange={(e) => setMfa(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{err && <div style={errorText}>{err}</div>}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy}
|
||||
style={{ ...btn, marginTop: 8, ...disabledStyle(busy) }}
|
||||
>
|
||||
{busy ? 'Signing in...' : mfaRequired ? 'Verify' : 'Sign in'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
import { useState } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useOperations } from '../hooks/use-mpc'
|
||||
import { api, type Operation } from '../lib/api'
|
||||
import { StatusBadge } from '../components/StatusBadge'
|
||||
import {
|
||||
card, label, btn, btnGhost, btnDanger,
|
||||
table, th, td, tHead, tRow, mono, h1, sub, errorText, disabledStyle, shorten,
|
||||
} from '../lib/styles'
|
||||
|
||||
const statusFilters = [
|
||||
{ v: '', l: 'All' },
|
||||
{ v: 'pending_approval', l: 'Pending approval' },
|
||||
{ v: 'approved', l: 'Approved' },
|
||||
{ v: 'signed', l: 'Signed' },
|
||||
{ v: 'rejected', l: 'Rejected' },
|
||||
{ v: 'failed', l: 'Failed' },
|
||||
{ v: 'expired', l: 'Expired' },
|
||||
]
|
||||
|
||||
export function Operations() {
|
||||
const [status, setStatus] = useState('pending_approval')
|
||||
const [page, setPage] = useState(1)
|
||||
const filters = { status: status || undefined, page, perPage: 25 }
|
||||
const { data, error } = useOperations(filters)
|
||||
const [selected, setSelected] = useState<Operation | null>(null)
|
||||
|
||||
const items = data?.items ?? []
|
||||
const total = data?.totalItems ?? 0
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||
<div>
|
||||
<h1 style={h1}>Operations</h1>
|
||||
<p style={sub}>
|
||||
Approval inbox. Pending intents need your signature; approved ones are signing now.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && <div style={errorText}>{(error as Error).message}</div>}
|
||||
|
||||
{/* Filter bar */}
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
{statusFilters.map((f) => (
|
||||
<button
|
||||
key={f.v}
|
||||
onClick={() => { setStatus(f.v); setPage(1) }}
|
||||
style={{
|
||||
...btnGhost,
|
||||
padding: '4px 12px',
|
||||
fontSize: 12,
|
||||
...(status === f.v ? { background: '#fff', color: '#000', border: '1px solid #fff' } : {}),
|
||||
}}
|
||||
>
|
||||
{f.l}
|
||||
</button>
|
||||
))}
|
||||
<div style={{ marginLeft: 'auto', fontSize: 12, color: '#737373' }}>
|
||||
{total} total
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: selected ? 'minmax(0, 1fr) 420px' : '1fr', gap: 16 }}>
|
||||
{/* Table */}
|
||||
<div>
|
||||
<table style={table}>
|
||||
<thead>
|
||||
<tr style={tHead}>
|
||||
<th style={th}>Op ID</th>
|
||||
<th style={th}>Kind</th>
|
||||
<th style={th}>Wallet</th>
|
||||
<th style={th}>To</th>
|
||||
<th style={th}>Amount</th>
|
||||
<th style={th}>Approvals</th>
|
||||
<th style={th}>Status</th>
|
||||
<th style={th}>Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((op) => {
|
||||
const to = (op.payload?.toAddress as string) ?? ''
|
||||
const amount = (op.payload?.amount as string) ?? ''
|
||||
const chain = (op.payload?.chain as string) ?? ''
|
||||
const isSelected = selected?.operationId === op.operationId
|
||||
return (
|
||||
<tr
|
||||
key={op.operationId}
|
||||
onClick={() => setSelected(op)}
|
||||
style={{
|
||||
...tRow,
|
||||
cursor: 'pointer',
|
||||
background: isSelected ? '#171717' : 'transparent',
|
||||
}}
|
||||
>
|
||||
<td style={{ ...td, ...mono, fontSize: 11 }}>{shorten(op.operationId, 8, 4)}</td>
|
||||
<td style={td}>{op.kind}</td>
|
||||
<td style={{ ...td, ...mono, fontSize: 11 }}>{shorten(op.walletId, 8, 4)}</td>
|
||||
<td style={{ ...td, ...mono, fontSize: 11 }}>{to ? shorten(to, 8, 4) : '--'}</td>
|
||||
<td style={{ ...td, ...mono }}>
|
||||
{amount ? `${amount} ${chain}` : '--'}
|
||||
</td>
|
||||
<td style={td}>{op.approvers?.length ?? 0}</td>
|
||||
<td style={td}><StatusBadge status={op.status} /></td>
|
||||
<td style={{ ...td, color: '#737373' }}>
|
||||
{new Date(op.createdAt).toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
{items.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={8} style={{ ...td, color: '#525252' }}>
|
||||
No operations match this filter.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* Pagination */}
|
||||
{total > 25 && (
|
||||
<div style={{ marginTop: 12, display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
style={{ ...btnGhost, ...disabledStyle(page === 1) }}
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
<span style={{ fontSize: 12, color: '#737373' }}>
|
||||
Page {page} of {Math.ceil(total / 25)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
disabled={page * 25 >= total}
|
||||
style={{ ...btnGhost, ...disabledStyle(page * 25 >= total) }}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Detail panel */}
|
||||
{selected && (
|
||||
<OperationDetail
|
||||
op={selected}
|
||||
onClose={() => setSelected(null)}
|
||||
onChange={() => {/* tanstack-query refetches via interval */}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function OperationDetail({
|
||||
op, onClose, onChange,
|
||||
}: {
|
||||
op: Operation
|
||||
onClose: () => void
|
||||
onChange: () => void
|
||||
}) {
|
||||
const qc = useQueryClient()
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [err, setErr] = useState('')
|
||||
const [reason, setReason] = useState('')
|
||||
const [notes, setNotes] = useState('')
|
||||
const [showReject, setShowReject] = useState(false)
|
||||
|
||||
async function approve() {
|
||||
setBusy(true); setErr('')
|
||||
try {
|
||||
await api.approveOperation(op.operationId, notes)
|
||||
qc.invalidateQueries({ queryKey: ['mpc', 'operations'] })
|
||||
onChange()
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : 'Approval failed')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function reject() {
|
||||
if (!reason.trim()) return
|
||||
setBusy(true); setErr('')
|
||||
try {
|
||||
await api.rejectOperation(op.operationId, reason)
|
||||
qc.invalidateQueries({ queryKey: ['mpc', 'operations'] })
|
||||
onChange()
|
||||
onClose()
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : 'Rejection failed')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const canDecide = op.status === 'pending_approval' || op.status === 'approved'
|
||||
|
||||
return (
|
||||
<div style={{ ...card, alignSelf: 'start', position: 'sticky', top: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div style={{ fontWeight: 600, color: '#fff' }}>Operation</div>
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', color: '#737373', cursor: 'pointer', fontSize: 18 }}>×</button>
|
||||
</div>
|
||||
|
||||
<div style={{ ...mono, fontSize: 11, color: '#737373', marginTop: 4 }}>{op.operationId}</div>
|
||||
|
||||
<Field title="Kind" value={op.kind} />
|
||||
<Field title="Status" value={<StatusBadge status={op.status} />} />
|
||||
<Field title="Wallet" value={<span style={mono}>{op.walletId}</span>} />
|
||||
|
||||
{op.payload && Object.keys(op.payload).length > 0 && (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<div style={label}>Payload</div>
|
||||
<div style={{ marginTop: 4, padding: 8, background: '#000', border: '1px solid #171717', borderRadius: 4 }}>
|
||||
{Object.entries(op.payload).map(([k, v]) => (
|
||||
<div key={k} style={{ fontSize: 11, ...mono, color: '#a3a3a3', wordBreak: 'break-all' }}>
|
||||
<span style={{ color: '#737373' }}>{k}:</span>{' '}
|
||||
{typeof v === 'string' ? v : JSON.stringify(v)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{op.approvals && op.approvals.length > 0 && (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<div style={label}>Approvals</div>
|
||||
{op.approvals.map((a) => (
|
||||
<div key={a.approverId} style={{ marginTop: 4, fontSize: 12 }}>
|
||||
<span style={mono}>{shorten(a.approverId, 8, 4)}</span>{' '}
|
||||
<span style={{ color: '#737373' }}>{new Date(a.approvedAt).toLocaleString()}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{op.result && Object.keys(op.result).length > 0 && (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<div style={label}>Result</div>
|
||||
{Object.entries(op.result).map(([k, v]) => (
|
||||
<div key={k} style={{ fontSize: 11, ...mono, color: '#a3a3a3', wordBreak: 'break-all', marginTop: 4 }}>
|
||||
<span style={{ color: '#737373' }}>{k}:</span> {v}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{op.rejectionReason && (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<div style={label}>Rejection reason</div>
|
||||
<div style={{ fontSize: 13, color: '#ef4444', marginTop: 4 }}>{op.rejectionReason}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{err && <div style={{ ...errorText, marginTop: 12 }}>{err}</div>}
|
||||
|
||||
{canDecide && (
|
||||
<div style={{ marginTop: 16, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{!showReject && (
|
||||
<>
|
||||
<input
|
||||
placeholder="Optional notes"
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
style={{
|
||||
background: '#0a0a0a', border: '1px solid #262626', borderRadius: 6,
|
||||
padding: '6px 12px', color: '#e5e5e5', fontSize: 13,
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={approve}
|
||||
disabled={busy}
|
||||
style={{ ...btn, ...disabledStyle(busy) }}
|
||||
>
|
||||
{busy ? 'Approving...' : 'Approve'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowReject(true)}
|
||||
disabled={busy}
|
||||
style={{ ...btnGhost, ...disabledStyle(busy) }}
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{showReject && (
|
||||
<>
|
||||
<input
|
||||
autoFocus
|
||||
placeholder="Reason (required)"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
style={{
|
||||
background: '#0a0a0a', border: '1px solid #262626', borderRadius: 6,
|
||||
padding: '6px 12px', color: '#e5e5e5', fontSize: 13,
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={reject}
|
||||
disabled={busy || !reason.trim()}
|
||||
style={{ ...btnDanger, ...disabledStyle(busy || !reason.trim()) }}
|
||||
>
|
||||
{busy ? 'Rejecting...' : 'Confirm reject'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setShowReject(false); setReason('') }}
|
||||
disabled={busy}
|
||||
style={{ ...btnGhost, ...disabledStyle(busy) }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({ title, value }: { title: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<div style={label}>{title}</div>
|
||||
<div style={{ fontSize: 13, marginTop: 2 }}>{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { useLocation } from 'wouter'
|
||||
import { useVaults, useWallets } from '../hooks/use-mpc'
|
||||
import { card, label, btn, h1, sub, errorText, disabledStyle } from '../lib/styles'
|
||||
|
||||
// Sign / build a transaction.
|
||||
//
|
||||
// The unified Operation view is read-only — Operation creation goes through
|
||||
// /v1/transactions which the backend keeps as the internal entry point. This
|
||||
// page collects the inputs, calls the legacy POST, and the user is routed to
|
||||
// /operations to approve and watch it sign.
|
||||
|
||||
const chains = ['ethereum', 'bitcoin', 'lux', 'solana', 'polygon', 'arbitrum', 'base', 'optimism']
|
||||
|
||||
export function Sign() {
|
||||
const [, navigate] = useLocation()
|
||||
const { data: vaults } = useVaults()
|
||||
const [vaultId, setVaultId] = useState('')
|
||||
const effectiveVault = vaultId || vaults?.[0]?.id || ''
|
||||
const { data: wallets } = useWallets(effectiveVault)
|
||||
|
||||
const [walletId, setWalletId] = useState('')
|
||||
const [chain, setChain] = useState('ethereum')
|
||||
const [to, setTo] = useState('')
|
||||
const [amount, setAmount] = useState('')
|
||||
const [token, setToken] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [err, setErr] = useState('')
|
||||
|
||||
async function submit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!walletId || !to || !amount) return
|
||||
setBusy(true); setErr('')
|
||||
try {
|
||||
const res = await fetch('/v1/transactions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('mpc_token') ?? ''}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
wallet_id: walletId,
|
||||
tx_type: 'send',
|
||||
chain,
|
||||
to_address: to,
|
||||
amount,
|
||||
token: token || undefined,
|
||||
}),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const txt = await res.text()
|
||||
throw new Error(txt || `HTTP ${res.status}`)
|
||||
}
|
||||
navigate('/operations')
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : 'Submission failed')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const selectedWallet = wallets?.find((w) => w.id === walletId)
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||
<div>
|
||||
<h1 style={h1}>New transaction</h1>
|
||||
<p style={sub}>
|
||||
Build a transaction and submit for approval. Goes through your org's policy chain
|
||||
before signing.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{err && <div style={errorText}>{err}</div>}
|
||||
|
||||
<form onSubmit={submit} style={{ ...card, maxWidth: 640, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<Field title="Vault">
|
||||
<select
|
||||
value={effectiveVault}
|
||||
onChange={(e) => { setVaultId(e.target.value); setWalletId('') }}
|
||||
style={fieldInput}
|
||||
>
|
||||
<option value="">Select a vault…</option>
|
||||
{(vaults ?? []).map((v) => <option key={v.id} value={v.id}>{v.name}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<Field title="Wallet">
|
||||
<select
|
||||
value={walletId}
|
||||
onChange={(e) => setWalletId(e.target.value)}
|
||||
disabled={!effectiveVault}
|
||||
style={fieldInput}
|
||||
>
|
||||
<option value="">Select a wallet…</option>
|
||||
{(wallets ?? []).map((w) => (
|
||||
<option key={w.id} value={w.id}>
|
||||
{w.name ?? w.wallet_id} ({w.key_type})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
{selectedWallet && (
|
||||
<div style={{ fontSize: 11, color: '#737373', fontFamily: 'monospace' }}>
|
||||
ETH: {selectedWallet.eth_address ?? '--'}
|
||||
{selectedWallet.btc_address && <><br />BTC: {selectedWallet.btc_address}</>}
|
||||
{selectedWallet.sol_address && <><br />SOL: {selectedWallet.sol_address}</>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Field title="Chain">
|
||||
<select value={chain} onChange={(e) => setChain(e.target.value)} style={fieldInput}>
|
||||
{chains.map((c) => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<Field title="Destination address">
|
||||
<input
|
||||
required
|
||||
placeholder="0x..."
|
||||
value={to}
|
||||
onChange={(e) => setTo(e.target.value)}
|
||||
style={{ ...fieldInput, fontFamily: 'monospace' }}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field title="Amount">
|
||||
<input
|
||||
required
|
||||
placeholder="0.0"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
style={{ ...fieldInput, fontFamily: 'monospace' }}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field title="Token contract (optional)">
|
||||
<input
|
||||
placeholder="0x... — leave blank for native"
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
style={{ ...fieldInput, fontFamily: 'monospace' }}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || !walletId || !to || !amount}
|
||||
style={{ ...btn, ...disabledStyle(busy || !walletId || !to || !amount), marginTop: 8 }}
|
||||
>
|
||||
{busy ? 'Submitting...' : 'Submit for approval'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<div style={label}>{title}</div>
|
||||
<div style={{ marginTop: 4 }}>{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const fieldInput: React.CSSProperties = {
|
||||
width: '100%',
|
||||
background: '#0a0a0a',
|
||||
border: '1px solid #262626',
|
||||
borderRadius: 6,
|
||||
padding: '6px 12px',
|
||||
color: '#e5e5e5',
|
||||
fontSize: 13,
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useUsers } from '../hooks/use-mpc'
|
||||
import { api, auth } from '../lib/api'
|
||||
import {
|
||||
card, label, btn, btnGhost,
|
||||
table, th, td, tHead, tRow, mono, h1, sub, errorText, disabledStyle, shorten,
|
||||
} from '../lib/styles'
|
||||
|
||||
const roles = ['viewer', 'signer', 'admin', 'owner']
|
||||
|
||||
export function Users() {
|
||||
const qc = useQueryClient()
|
||||
const { data: users, error } = useUsers()
|
||||
const me = auth.user()
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [role, setRole] = useState('viewer')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [err, setErr] = useState('')
|
||||
|
||||
async function invite(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!email.trim() || !password) return
|
||||
setBusy(true); setErr('')
|
||||
try {
|
||||
await api.inviteUser({ email: email.trim(), password, role })
|
||||
setEmail(''); setPassword(''); setRole('viewer')
|
||||
qc.invalidateQueries({ queryKey: ['mpc', 'users'] })
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : 'Invite failed')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function changeRole(id: string, newRole: string) {
|
||||
try {
|
||||
await api.updateUser(id, { role: newRole })
|
||||
qc.invalidateQueries({ queryKey: ['mpc', 'users'] })
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : 'Update failed')
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(id: string) {
|
||||
try {
|
||||
await api.deleteUser(id)
|
||||
qc.invalidateQueries({ queryKey: ['mpc', 'users'] })
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : 'Delete failed')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||
<div>
|
||||
<h1 style={h1}>Users</h1>
|
||||
<p style={sub}>
|
||||
Org members with role-based permissions. Roles: viewer, signer, admin, owner.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{err && <div style={errorText}>{err}</div>}
|
||||
|
||||
<form onSubmit={invite} style={card}>
|
||||
<div style={{ ...label, marginBottom: 8 }}>Invite user</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 200px 140px auto', gap: 8 }}>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="[email protected]"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
style={{
|
||||
background: '#0a0a0a', border: '1px solid #262626', borderRadius: 6,
|
||||
padding: '6px 12px', color: '#e5e5e5', fontSize: 13,
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Initial password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
style={{
|
||||
background: '#0a0a0a', border: '1px solid #262626', borderRadius: 6,
|
||||
padding: '6px 12px', color: '#e5e5e5', fontSize: 13,
|
||||
}}
|
||||
/>
|
||||
<select
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value)}
|
||||
style={{
|
||||
background: '#0a0a0a', border: '1px solid #262626', borderRadius: 6,
|
||||
padding: '6px 12px', color: '#e5e5e5', fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{roles.map((r) => <option key={r} value={r}>{r}</option>)}
|
||||
</select>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || !email.trim() || !password}
|
||||
style={{ ...btn, ...disabledStyle(busy || !email.trim() || !password) }}
|
||||
>
|
||||
{busy ? 'Inviting...' : 'Invite'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{error && <div style={errorText}>{(error as Error).message}</div>}
|
||||
|
||||
<table style={table}>
|
||||
<thead>
|
||||
<tr style={tHead}>
|
||||
<th style={th}>Email</th>
|
||||
<th style={th}>Role</th>
|
||||
<th style={th}>ID</th>
|
||||
<th style={th}>Created</th>
|
||||
<th style={{ ...th, textAlign: 'right' }}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(users ?? []).map((u) => {
|
||||
const isMe = me?.id === u.id
|
||||
return (
|
||||
<tr key={u.id} style={tRow}>
|
||||
<td style={td}>
|
||||
{u.email} {isMe && <span style={{ fontSize: 11, color: '#22c55e', marginLeft: 4 }}>(you)</span>}
|
||||
</td>
|
||||
<td style={td}>
|
||||
<select
|
||||
value={u.role}
|
||||
onChange={(e) => changeRole(u.id, e.target.value)}
|
||||
disabled={isMe}
|
||||
style={{
|
||||
background: '#0a0a0a', border: '1px solid #262626', borderRadius: 4,
|
||||
padding: '2px 8px', color: '#e5e5e5', fontSize: 12,
|
||||
}}
|
||||
>
|
||||
{roles.map((r) => <option key={r} value={r}>{r}</option>)}
|
||||
</select>
|
||||
</td>
|
||||
<td style={{ ...td, ...mono, fontSize: 11 }}>{shorten(u.id, 10, 4)}</td>
|
||||
<td style={{ ...td, color: '#737373' }}>{new Date(u.createdAt).toLocaleString()}</td>
|
||||
<td style={{ ...td, textAlign: 'right' }}>
|
||||
<button
|
||||
onClick={() => remove(u.id)}
|
||||
disabled={isMe}
|
||||
style={{
|
||||
...btnGhost, padding: '3px 10px', color: '#ef4444', borderColor: '#ef4444',
|
||||
...disabledStyle(isMe),
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
{(users ?? []).length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} style={{ ...td, color: '#525252' }}>
|
||||
No users.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useWebhooks } from '../hooks/use-mpc'
|
||||
import { api } from '../lib/api'
|
||||
import { StatusBadge } from '../components/StatusBadge'
|
||||
import {
|
||||
card, label, btn, btnGhost,
|
||||
table, th, td, tHead, tRow, mono, h1, sub, errorText, disabledStyle,
|
||||
} from '../lib/styles'
|
||||
|
||||
const eventOptions = [
|
||||
'transaction.created',
|
||||
'transaction.signed',
|
||||
'transaction.broadcast',
|
||||
'transaction.confirmed',
|
||||
'transaction.failed',
|
||||
'operation.pending_approval',
|
||||
'operation.approved',
|
||||
'operation.rejected',
|
||||
'wallet.created',
|
||||
'policy.violation',
|
||||
'session.created',
|
||||
'session.revoked',
|
||||
]
|
||||
|
||||
export function Webhooks() {
|
||||
const qc = useQueryClient()
|
||||
const { data: webhooks, error } = useWebhooks()
|
||||
const [url, setUrl] = useState('')
|
||||
const [secret, setSecret] = useState('')
|
||||
const [events, setEvents] = useState<Set<string>>(new Set())
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [err, setErr] = useState('')
|
||||
const [info, setInfo] = useState('')
|
||||
|
||||
function toggle(e: string) {
|
||||
const n = new Set(events)
|
||||
if (n.has(e)) n.delete(e); else n.add(e)
|
||||
setEvents(n)
|
||||
}
|
||||
|
||||
async function create(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!url.trim() || !secret.trim() || events.size === 0) return
|
||||
setBusy(true); setErr(''); setInfo('')
|
||||
try {
|
||||
await api.createWebhook({
|
||||
url: url.trim(),
|
||||
secret: secret.trim(),
|
||||
events: Array.from(events),
|
||||
})
|
||||
setUrl(''); setSecret(''); setEvents(new Set())
|
||||
qc.invalidateQueries({ queryKey: ['mpc', 'webhooks'] })
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : 'Create failed')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleEnabled(id: string, enabled: boolean) {
|
||||
try {
|
||||
await api.updateWebhook(id, { enabled: !enabled })
|
||||
qc.invalidateQueries({ queryKey: ['mpc', 'webhooks'] })
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : 'Update failed')
|
||||
}
|
||||
}
|
||||
|
||||
async function test(id: string) {
|
||||
try {
|
||||
const r = await api.testWebhook(id)
|
||||
setInfo(`Test sent: ${r.status}`)
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : 'Test failed')
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(id: string) {
|
||||
try {
|
||||
await api.deleteWebhook(id)
|
||||
qc.invalidateQueries({ queryKey: ['mpc', 'webhooks'] })
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : 'Delete failed')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||
<div>
|
||||
<h1 style={h1}>Webhooks</h1>
|
||||
<p style={sub}>
|
||||
Outbound HTTP delivery of MPC events. Each request is HMAC-signed with the webhook secret.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{err && <div style={errorText}>{err}</div>}
|
||||
{info && <div style={{ color: '#22c55e', fontSize: 13 }}>{info}</div>}
|
||||
|
||||
<form onSubmit={create} style={card}>
|
||||
<div style={{ ...label, marginBottom: 8 }}>New webhook</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
|
||||
<input
|
||||
placeholder="https://example.com/webhook"
|
||||
required
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
style={{
|
||||
background: '#0a0a0a', border: '1px solid #262626', borderRadius: 6,
|
||||
padding: '6px 12px', color: '#e5e5e5', fontSize: 13,
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
placeholder="Shared HMAC secret"
|
||||
required
|
||||
value={secret}
|
||||
onChange={(e) => setSecret(e.target.value)}
|
||||
style={{
|
||||
background: '#0a0a0a', border: '1px solid #262626', borderRadius: 6,
|
||||
padding: '6px 12px', color: '#e5e5e5', fontSize: 13, ...mono,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ ...label, marginTop: 12 }}>Events</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 8 }}>
|
||||
{eventOptions.map((ev) => {
|
||||
const on = events.has(ev)
|
||||
return (
|
||||
<button
|
||||
key={ev}
|
||||
type="button"
|
||||
onClick={() => toggle(ev)}
|
||||
style={{
|
||||
...btnGhost, padding: '3px 10px', fontSize: 11, ...mono,
|
||||
...(on ? { background: '#3b82f620', color: '#3b82f6', borderColor: '#3b82f6' } : {}),
|
||||
}}
|
||||
>
|
||||
{ev}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || !url.trim() || !secret.trim() || events.size === 0}
|
||||
style={{
|
||||
...btn, marginTop: 12,
|
||||
...disabledStyle(busy || !url.trim() || !secret.trim() || events.size === 0),
|
||||
}}
|
||||
>
|
||||
{busy ? 'Creating...' : 'Create webhook'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{error && <div style={errorText}>{(error as Error).message}</div>}
|
||||
|
||||
<table style={table}>
|
||||
<thead>
|
||||
<tr style={tHead}>
|
||||
<th style={th}>URL</th>
|
||||
<th style={th}>Events</th>
|
||||
<th style={th}>Status</th>
|
||||
<th style={th}>Created</th>
|
||||
<th style={{ ...th, textAlign: 'right' }}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(webhooks ?? []).map((w) => (
|
||||
<tr key={w.id} style={tRow}>
|
||||
<td style={{ ...td, ...mono, fontSize: 11 }}>{w.url}</td>
|
||||
<td style={{ ...td, fontSize: 11, color: '#a3a3a3' }}>{w.events.join(', ')}</td>
|
||||
<td style={td}><StatusBadge status={w.enabled ? 'enabled' : 'disabled'} /></td>
|
||||
<td style={{ ...td, color: '#737373' }}>{new Date(w.created_at).toLocaleString()}</td>
|
||||
<td style={{ ...td, textAlign: 'right' }}>
|
||||
<button onClick={() => test(w.id)} style={{ ...btnGhost, padding: '3px 10px', marginRight: 4 }}>Test</button>
|
||||
<button onClick={() => toggleEnabled(w.id, w.enabled)} style={{ ...btnGhost, padding: '3px 10px', marginRight: 4 }}>
|
||||
{w.enabled ? 'Disable' : 'Enable'}
|
||||
</button>
|
||||
<button onClick={() => remove(w.id)} style={{ ...btnGhost, padding: '3px 10px', color: '#ef4444', borderColor: '#ef4444' }}>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{(webhooks ?? []).length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} style={{ ...td, color: '#525252' }}>
|
||||
No webhooks configured.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useWhitelist } from '../hooks/use-mpc'
|
||||
import { api } from '../lib/api'
|
||||
import {
|
||||
card, label, btn, btnGhost,
|
||||
table, th, td, tHead, tRow, mono, h1, sub, errorText, disabledStyle,
|
||||
} from '../lib/styles'
|
||||
|
||||
const chains = ['ethereum', 'bitcoin', 'lux', 'solana', 'polygon', 'arbitrum', 'base', 'optimism']
|
||||
|
||||
export function Whitelist() {
|
||||
const qc = useQueryClient()
|
||||
const { data: entries, error } = useWhitelist()
|
||||
const [address, setAddress] = useState('')
|
||||
const [chain, setChain] = useState('ethereum')
|
||||
const [labelText, setLabelText] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [err, setErr] = useState('')
|
||||
|
||||
async function add(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!address.trim()) return
|
||||
setBusy(true); setErr('')
|
||||
try {
|
||||
await api.addWhitelist({
|
||||
address: address.trim(),
|
||||
chain,
|
||||
label: labelText.trim() || undefined,
|
||||
})
|
||||
setAddress(''); setLabelText('')
|
||||
qc.invalidateQueries({ queryKey: ['mpc', 'whitelist'] })
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : 'Add failed')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(id: string) {
|
||||
try {
|
||||
await api.deleteWhitelist(id)
|
||||
qc.invalidateQueries({ queryKey: ['mpc', 'whitelist'] })
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : 'Remove failed')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||
<div>
|
||||
<h1 style={h1}>Allowlist</h1>
|
||||
<p style={sub}>
|
||||
Permit destination addresses that policies can require for transfers.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{err && <div style={errorText}>{err}</div>}
|
||||
|
||||
<form onSubmit={add} style={card}>
|
||||
<div style={{ ...label, marginBottom: 8 }}>Add destination</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 160px 200px auto', gap: 8 }}>
|
||||
<input
|
||||
placeholder="Address"
|
||||
required
|
||||
value={address}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
style={{
|
||||
background: '#0a0a0a', border: '1px solid #262626', borderRadius: 6,
|
||||
padding: '6px 12px', color: '#e5e5e5', fontSize: 13, fontFamily: 'monospace',
|
||||
}}
|
||||
/>
|
||||
<select
|
||||
value={chain}
|
||||
onChange={(e) => setChain(e.target.value)}
|
||||
style={{
|
||||
background: '#0a0a0a', border: '1px solid #262626', borderRadius: 6,
|
||||
padding: '6px 12px', color: '#e5e5e5', fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{chains.map((c) => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
<input
|
||||
placeholder="Label (optional)"
|
||||
value={labelText}
|
||||
onChange={(e) => setLabelText(e.target.value)}
|
||||
style={{
|
||||
background: '#0a0a0a', border: '1px solid #262626', borderRadius: 6,
|
||||
padding: '6px 12px', color: '#e5e5e5', fontSize: 13,
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || !address.trim()}
|
||||
style={{ ...btn, ...disabledStyle(busy || !address.trim()) }}
|
||||
>
|
||||
{busy ? 'Adding...' : 'Add'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{error && <div style={errorText}>{(error as Error).message}</div>}
|
||||
|
||||
<table style={table}>
|
||||
<thead>
|
||||
<tr style={tHead}>
|
||||
<th style={th}>Address</th>
|
||||
<th style={th}>Chain</th>
|
||||
<th style={th}>Label</th>
|
||||
<th style={th}>Created</th>
|
||||
<th style={{ ...th, textAlign: 'right' }}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(entries ?? []).map((e) => (
|
||||
<tr key={e.id} style={tRow}>
|
||||
<td style={{ ...td, ...mono, fontSize: 11 }}>{e.address}</td>
|
||||
<td style={td}>{e.chain}</td>
|
||||
<td style={td}>{e.label || '--'}</td>
|
||||
<td style={{ ...td, color: '#737373' }}>{new Date(e.created_at).toLocaleString()}</td>
|
||||
<td style={{ ...td, textAlign: 'right' }}>
|
||||
<button
|
||||
onClick={() => remove(e.id)}
|
||||
style={{ ...btnGhost, padding: '4px 12px' }}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{(entries ?? []).length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} style={{ ...td, color: '#525252' }}>
|
||||
No allowlisted destinations.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user