feat: MPC wallet dashboard - full-stack implementation

Complete MPC wallet management platform rivaling Fireblocks/Utila/Fordefi:

Backend (Go):
- REST API with 60+ endpoints on chi router (pkg/api/)
- PostgreSQL schema with 13 tables (pkg/db/)
- JWT + MFA auth, bcrypt passwords, API key management
- Policy engine (deny/approve/require_approval)
- Transaction signing flow with approval workflow
- Subscription scheduler for recurring payments
- Payment requests with public pay pages
- Safe + ERC-4337 smart wallet integration (pkg/smart/)
- Rate limiting, webhook delivery (HMAC-SHA256), audit logging
- 27 passing tests (auth, policy engine, middleware)

Frontend (Next.js 14):
- 27 routes with monochrome dark theme (Tailwind + Geist fonts)
- Full API client with JWT auto-refresh
- React Query hooks for all resources
- Pages: dashboard, vaults, wallets, transactions, policies,
  whitelist, team, settings, API keys, webhooks, audit,
  subscriptions, payments, smart wallets

Infrastructure:
- Dockerfiles for API (Go) and dashboard (Next.js standalone)
- compose.yml for local development (PostgreSQL + API + dashboard)
- K8s manifests for mpc.lux.network + cloud.lux.network
- GitHub Actions deploy workflow (build → push GHCR → kubectl apply)
- TLS via cert-manager, Traefik IngressRoute for both domains
This commit is contained in:
2026-02-28 13:27:34 -08:00
parent 113615960a
commit f110e06800
117 changed files with 19455 additions and 52 deletions
+6
View File
@@ -0,0 +1,6 @@
.git
*.md
dashboard/node_modules
dashboard/.next
node_modules
.env
+8
View File
@@ -0,0 +1,8 @@
# PostgreSQL
POSTGRES_PASSWORD=your-secure-password-here
# JWT Secret (generate with: openssl rand -hex 32)
JWT_SECRET=your-jwt-secret-here
# API URL for dashboard
NEXT_PUBLIC_API_URL=http://localhost:8081/api/v1
+130
View File
@@ -0,0 +1,130 @@
name: Build & Deploy MPC Dashboard
on:
push:
branches: [main]
tags: ['v*']
workflow_dispatch:
env:
REGISTRY: ghcr.io
jobs:
build-api:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/luxfi/mpc-api
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push API
uses: docker/build-push-action@v5
with:
context: .
file: Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64
provenance: false
sbom: false
cache-from: type=gha,scope=api
cache-to: type=gha,mode=max,scope=api
build-dashboard:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/luxfi/mpc-dashboard
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push Dashboard
uses: docker/build-push-action@v5
with:
context: ./dashboard
file: ./dashboard/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64
provenance: false
sbom: false
cache-from: type=gha,scope=dashboard
cache-to: type=gha,mode=max,scope=dashboard
build-args: |
NEXT_PUBLIC_API_URL=https://mpc.lux.network/api/v1
deploy:
needs: [build-api, build-dashboard]
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
steps:
- uses: actions/checkout@v4
- name: Install doctl
uses: digitalocean/action-doctl@v2
with:
token: ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }}
- name: Configure kubectl
run: doctl kubernetes cluster kubeconfig save lux-k8s
- name: Apply K8s manifests
run: |
kubectl apply -f k8s/namespace.yaml
kubectl apply -f k8s/secrets.yaml
kubectl apply -f k8s/certificate.yaml
kubectl apply -f k8s/postgres.yaml
kubectl apply -f k8s/api-service.yaml
kubectl apply -f k8s/api-deployment.yaml
kubectl apply -f k8s/dashboard-service.yaml
kubectl apply -f k8s/dashboard-deployment.yaml
kubectl apply -f k8s/ingressroute.yaml
- name: Restart deployments
run: |
kubectl rollout restart deployment/mpc-api -n lux-mpc
kubectl rollout restart deployment/mpc-dashboard -n lux-mpc
- name: Wait for rollout
run: |
kubectl rollout status deployment/mpc-api -n lux-mpc --timeout=120s
kubectl rollout status deployment/mpc-dashboard -n lux-mpc --timeout=120s
+22 -7
View File
@@ -1,6 +1,6 @@
db/
tmp/
bin/
/db/
/tmp/
/bin/
identity/
event_initiator.identity.json
event_initiator.key
@@ -20,10 +20,25 @@ e2e/config.test.yaml
logs/
# Built binaries
lux-mpc
lux-mpc-bridge
lux-mpc-cli
mpcd
/lux-mpc
/lux-mpc-bridge
/lux-mpc-cli
/mpcd
*-linux-amd64
*-linux-arm64
*-darwin-amd64
*-darwin-arm64
# Docker build artifacts
.docker-build/
# Dashboard build
dashboard/node_modules/
dashboard/.next/
# Environment
.env
.env.local
+9 -45
View File
@@ -1,50 +1,14 @@
# Build stage
FROM golang:1.25-alpine AS builder
FROM golang:1.23-alpine AS builder
RUN apk add --no-cache git make
WORKDIR /build
# Copy go mod files
WORKDIR /src
COPY go.mod go.sum ./
# Download dependencies
RUN go mod download
# Copy source code
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /mpcd ./cmd/mpcd/
# Build the binaries (CGO_ENABLED=0 for static, GOFIPS140=off to avoid QEMU FIPS issue)
RUN CGO_ENABLED=0 GOFIPS140=off go build -o mpcd ./cmd/mpcd
RUN CGO_ENABLED=0 GOFIPS140=off go build -o lux-mpc-cli ./cmd/lux-mpc-cli
# Runtime stage
FROM alpine:latest
RUN apk add --no-cache ca-certificates curl bash
WORKDIR /app
# Copy binaries from builder
COPY --from=builder /build/mpcd /usr/local/bin/mpcd
COPY --from=builder /build/lux-mpc-cli /usr/local/bin/lux-mpc-cli
# Symlink for backward compatibility (StatefulSet runs /usr/local/bin/lux-mpc)
RUN ln -s /usr/local/bin/mpcd /usr/local/bin/lux-mpc
# Copy config templates
COPY config.yaml.template /app/
COPY peers.json /app/
# Create data and log directories
RUN mkdir -p /data/mpc /app/logs /app/identity
# Expose ports
EXPOSE 6000 8080 9090
# Health check
HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=3 \
CMD curl -f http://localhost:8080/health || exit 1
# Default command
CMD ["mpcd", "start", "--config", "/app/config.yaml"]
FROM alpine:3.20
RUN apk add --no-cache ca-certificates
COPY --from=builder /mpcd /usr/local/bin/mpcd
COPY --from=builder /src/pkg/db/migrations /migrations
EXPOSE 8081
ENTRYPOINT ["mpcd"]
+186
View File
@@ -24,9 +24,11 @@ import (
"github.com/urfave/cli/v3"
"golang.org/x/term"
mpcapi "github.com/luxfi/mpc/pkg/api"
"github.com/luxfi/mpc/pkg/backup"
"github.com/luxfi/mpc/pkg/config"
"github.com/luxfi/mpc/pkg/constant"
"github.com/luxfi/mpc/pkg/db"
"github.com/luxfi/mpc/pkg/event"
"github.com/luxfi/mpc/pkg/eventconsumer"
"github.com/luxfi/mpc/pkg/identity"
@@ -105,6 +107,20 @@ func main() {
Usage: "Log level (debug, info, warn, error)",
Value: "info",
},
// Dashboard API flags
&cli.StringFlag{
Name: "api-db",
Usage: "PostgreSQL connection URL for dashboard API",
},
&cli.StringFlag{
Name: "api-listen",
Usage: "Dashboard API listen address",
Value: ":8081",
},
&cli.StringFlag{
Name: "jwt-secret",
Usage: "JWT signing secret for dashboard auth",
},
// Legacy mode flags
&cli.BoolFlag{
Name: "decrypt-private-key",
@@ -878,6 +894,56 @@ func runNodeConsensus(ctx context.Context, c *cli.Command) error {
}
}
// Start Dashboard API server if PostgreSQL URL is provided
apiDBURL := c.String("api-db")
if apiDBURL == "" {
apiDBURL = os.Getenv("MPC_API_DB")
}
if apiDBURL != "" {
apiListenAddr := c.String("api-listen")
jwtSecret := c.String("jwt-secret")
if jwtSecret == "" {
jwtSecret = os.Getenv("MPC_JWT_SECRET")
}
if jwtSecret == "" {
jwtSecret = "change-me-in-production"
logger.Warn("Using default JWT secret - NOT for production!")
}
database, err := db.New(ctx, apiDBURL)
if err != nil {
logger.Error("Failed to connect to dashboard database", err)
} else {
defer database.Close()
if err := database.Migrate(ctx); err != nil {
logger.Error("Failed to run database migrations", err)
} else {
mpcBackend := &ConsensusMPCBackend{
pubSub: pubSub,
peerRegistry: peerRegistry,
factory: factory,
nodeID: nodeID,
threshold: int(threshold),
}
apiServer := mpcapi.NewServer(database, mpcBackend, jwtSecret, apiListenAddr)
apiServer.StartScheduler(ctx)
logger.Info("Dashboard API server starting", "addr", apiListenAddr)
_, apiErrCh := apiServer.Start()
go func() {
if err := <-apiErrCh; err != nil {
logger.Error("Dashboard API server failed", err)
}
}()
defer apiServer.Shutdown(context.Background())
logger.Info("Dashboard API ready", "addr", apiListenAddr)
}
}
}
// Wait for shutdown signal
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
@@ -887,6 +953,126 @@ func runNodeConsensus(ctx context.Context, c *cli.Command) error {
return nil
}
// ConsensusMPCBackend implements api.MPCBackend using the consensus transport.
type ConsensusMPCBackend struct {
pubSub *ConsensusPubSubAdapter
peerRegistry *ConsensusPeerRegistry
factory *transport.Factory
nodeID string
threshold int
}
func (b *ConsensusMPCBackend) TriggerKeygen(walletID string) (*mpcapi.KeygenResult, error) {
if walletID == "" {
h := sha256.Sum256([]byte(fmt.Sprintf("%s-%d", b.nodeID, time.Now().UnixNano())))
walletID = hex.EncodeToString(h[:16])
}
resultTopic := fmt.Sprintf("mpc.mpc_keygen_result.%s", walletID)
resultCh := make(chan *event.KeygenResultEvent, 1)
unsub, err := b.pubSub.Subscribe(resultTopic, func(natMsg *nats.Msg) {
var result event.KeygenResultEvent
if err := json.Unmarshal(natMsg.Data, &result); err == nil {
select {
case resultCh <- &result:
default:
}
}
})
if err != nil {
return nil, fmt.Errorf("failed to subscribe to result topic: %w", err)
}
defer unsub.Unsubscribe()
msg := types.GenerateKeyMessage{WalletID: walletID}
msgData, _ := json.Marshal(msg)
if err := b.pubSub.Publish("mpc:generate", msgData); err != nil {
return nil, fmt.Errorf("failed to publish keygen: %w", err)
}
select {
case result := <-resultCh:
if result.ResultType != event.ResultTypeSuccess {
return nil, fmt.Errorf("keygen failed: %s", result.ErrorReason)
}
ethAddr := ""
if len(result.ECDSAPubKey) >= 33 {
ethAddr = pubKeyToEthAddress(result.ECDSAPubKey)
}
return &mpcapi.KeygenResult{
WalletID: result.WalletID,
ECDSAPubKey: hex.EncodeToString(result.ECDSAPubKey),
EDDSAPubKey: hex.EncodeToString(result.EDDSAPubKey),
EthAddress: ethAddr,
}, nil
case <-time.After(120 * time.Second):
return nil, fmt.Errorf("keygen timed out after 120s")
}
}
func (b *ConsensusMPCBackend) TriggerSign(walletID string, payload []byte) (*mpcapi.SignResult, error) {
txID := fmt.Sprintf("sign-%d", time.Now().UnixNano())
resultTopic := fmt.Sprintf("mpc.mpc_signing_result.%s", txID)
resultCh := make(chan json.RawMessage, 1)
unsub, err := b.pubSub.Subscribe(resultTopic, func(natMsg *nats.Msg) {
select {
case resultCh <- natMsg.Data:
default:
}
})
if err != nil {
return nil, fmt.Errorf("failed to subscribe to signing result: %w", err)
}
defer unsub.Unsubscribe()
msg := types.SignTxMessage{
WalletID: walletID,
TxID: txID,
Tx: payload,
}
msgData, _ := json.Marshal(msg)
if err := b.pubSub.Publish("mpc:sign", msgData); err != nil {
return nil, fmt.Errorf("failed to publish sign request: %w", err)
}
select {
case data := <-resultCh:
var result struct {
R string `json:"r"`
S string `json:"s"`
Signature string `json:"signature"`
}
json.Unmarshal(data, &result)
return &mpcapi.SignResult{R: result.R, S: result.S, Signature: result.Signature}, nil
case <-time.After(60 * time.Second):
return nil, fmt.Errorf("signing timed out after 60s")
}
}
func (b *ConsensusMPCBackend) TriggerReshare(walletID string, newThreshold int, newParticipants []string) error {
msg := map[string]interface{}{
"wallet_id": walletID,
"new_threshold": newThreshold,
"new_participants": newParticipants,
}
msgData, _ := json.Marshal(msg)
return b.pubSub.Publish("mpc:reshare", msgData)
}
func (b *ConsensusMPCBackend) GetClusterStatus() *mpcapi.ClusterStatus {
ready := b.peerRegistry.ArePeersReady()
connected := b.factory.Transport().GetPeers()
return &mpcapi.ClusterStatus{
NodeID: b.nodeID,
Mode: "consensus",
ExpectedPeers: len(b.peerRegistry.peerIDs),
ConnectedPeers: len(connected),
Ready: ready,
Threshold: b.threshold,
Version: Version,
}
}
// loadOrGenerateIdentity loads or generates Ed25519 identity
func loadOrGenerateIdentity(keysDir, nodeID string) (ed25519.PrivateKey, ed25519.PublicKey, error) {
identityPath := filepath.Join(keysDir, nodeID+"_identity.json")
+47
View File
@@ -0,0 +1,47 @@
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: mpc_api
POSTGRES_USER: mpc
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-changeme}
volumes:
- pgdata:/var/lib/postgresql/data
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U mpc -d mpc_api"]
interval: 5s
timeout: 3s
retries: 5
api:
build:
context: .
dockerfile: Dockerfile
command: >
start --mode consensus
--api-db postgres://mpc:${POSTGRES_PASSWORD:-changeme}@postgres:5432/mpc_api?sslmode=disable
--api-listen :8081
--jwt-secret ${JWT_SECRET:-dev-jwt-secret-change-in-production}
ports:
- "8081:8081"
depends_on:
postgres:
condition: service_healthy
environment:
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-changeme}
dashboard:
build:
context: ./dashboard
dockerfile: Dockerfile
ports:
- "3000:3000"
environment:
- NEXT_PUBLIC_API_URL=http://localhost:8081/api/v1
depends_on:
- api
volumes:
pgdata:
+4
View File
@@ -0,0 +1,4 @@
.git
node_modules
.next
.env*
+2
View File
@@ -0,0 +1,2 @@
# MPC API URL
NEXT_PUBLIC_API_URL=http://localhost:8081/api/v1
+27
View File
@@ -0,0 +1,27 @@
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml* package-lock.json* ./
RUN corepack enable && corepack prepare pnpm@latest --activate || true
RUN if [ -f pnpm-lock.yaml ]; then pnpm install --frozen-lockfile; \
elif [ -f package-lock.json ]; then npm ci; \
else npm install; fi
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
CMD ["node", "server.js"]
+77
View File
@@ -0,0 +1,77 @@
'use client'
import { useState, useEffect } from 'react'
import { Nav } from '@/components/layout/nav'
import { api } from '@/lib/api'
import type { AuditEntry } from '@/lib/types'
export default function AuditPage() {
const [entries, setEntries] = useState<AuditEntry[]>([])
const [error, setError] = useState('')
useEffect(() => {
api.listAudit().then(setEntries).catch((e) => setError(e.message))
}, [])
return (
<>
<Nav />
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<div className="mb-8">
<h1 className="text-2xl font-semibold tracking-tight">Audit Log</h1>
<p className="mt-1 text-sm text-muted-foreground">Track all actions performed in the system.</p>
</div>
{error && <p className="mb-4 text-sm text-destructive">{error}</p>}
<div className="overflow-x-auto rounded-lg border border-border">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-muted/50">
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Action</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Resource</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">User</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Date</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">IP</th>
</tr>
</thead>
<tbody>
{entries.map((entry) => (
<tr key={entry.id} className="border-b border-border last:border-0">
<td className="px-4 py-3 font-medium">{entry.action}</td>
<td className="px-4 py-3">
{entry.resource_type && (
<span className="text-muted-foreground">
{entry.resource_type}
{entry.resource_id && (
<span className="ml-1 font-mono text-xs">{entry.resource_id.slice(0, 12)}...</span>
)}
</span>
)}
{!entry.resource_type && <span className="text-muted-foreground">--</span>}
</td>
<td className="px-4 py-3 font-mono text-xs text-muted-foreground">
{entry.user_id || '--'}
</td>
<td className="px-4 py-3 text-muted-foreground">
{new Date(entry.created_at).toLocaleString()}
</td>
<td className="px-4 py-3 font-mono text-xs text-muted-foreground">
{entry.ip_address || '--'}
</td>
</tr>
))}
{entries.length === 0 && (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-muted-foreground">
No audit entries.
</td>
</tr>
)}
</tbody>
</table>
</div>
</main>
</>
)
}
+91
View File
@@ -0,0 +1,91 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 240 5.9% 10%;
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--radius: 0.5rem;
}
.dark {
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 240 4.9% 83.9%;
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground antialiased;
font-feature-settings: "rlig" 1, "calt" 1;
}
}
/* Scrollbar styles */
@layer utilities {
.scrollbar-thin {
scrollbar-width: thin;
scrollbar-color: hsl(var(--muted)) transparent;
}
.scrollbar-thin::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.scrollbar-thin::-webkit-scrollbar-track {
background: transparent;
}
.scrollbar-thin::-webkit-scrollbar-thumb {
background-color: hsl(var(--muted));
border-radius: 3px;
}
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
background-color: hsl(var(--muted-foreground));
}
.scrollbar-none {
scrollbar-width: none;
}
.scrollbar-none::-webkit-scrollbar {
display: none;
}
}
+26
View File
@@ -0,0 +1,26 @@
import type { Metadata } from 'next'
import { GeistSans } from 'geist/font/sans'
import { GeistMono } from 'geist/font/mono'
import { Providers } from './providers'
import './globals.css'
export const metadata: Metadata = {
title: 'Lux MPC',
description: 'MPC Wallet Management Dashboard',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en" className={`dark ${GeistSans.variable} ${GeistMono.variable}`}>
<body className="font-sans min-h-screen bg-background text-foreground antialiased">
<Providers>
{children}
</Providers>
</body>
</html>
)
}
+124
View File
@@ -0,0 +1,124 @@
'use client'
import { useState, type FormEvent } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { api, APIError } from '@/lib/api'
import { setTokens } from '@/lib/auth'
import type { AuthResponse } from '@/lib/types'
export default function LoginPage() {
const router = useRouter()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [mfaCode, setMfaCode] = useState('')
const [showMfa, setShowMfa] = useState(false)
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
async function handleSubmit(e: FormEvent) {
e.preventDefault()
setError('')
setLoading(true)
try {
const data = await api.login({ email, password, mfa_code: mfaCode || undefined })
if ('mfa_required' in data) {
setShowMfa(true)
setLoading(false)
return
}
const auth = data as AuthResponse
setTokens(auth.access_token, auth.refresh_token)
router.push('/')
} catch (err) {
setError(err instanceof APIError ? err.message : 'Login failed')
} finally {
setLoading(false)
}
}
return (
<div className="flex min-h-screen items-center justify-center px-4">
<div className="w-full max-w-sm">
<div className="rounded-lg border border-border bg-card p-8">
<h1 className="mb-6 text-center text-xl font-semibold tracking-tight">
Lux MPC
</h1>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="email" className="mb-1.5 block text-sm font-medium text-muted-foreground">
Email
</label>
<input
id="email"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="[email protected]"
/>
</div>
<div>
<label htmlFor="password" className="mb-1.5 block text-sm font-medium text-muted-foreground">
Password
</label>
<input
id="password"
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="Enter password"
/>
</div>
{showMfa && (
<div>
<label htmlFor="mfa" className="mb-1.5 block text-sm font-medium text-muted-foreground">
MFA Code
</label>
<input
id="mfa"
type="text"
inputMode="numeric"
pattern="[0-9]{6}"
maxLength={6}
value={mfaCode}
onChange={(e) => setMfaCode(e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono tracking-widest ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="000000"
/>
</div>
)}
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
<button
type="submit"
disabled={loading}
className="w-full rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
>
{loading ? 'Signing in...' : 'Sign in'}
</button>
</form>
<p className="mt-4 text-center text-sm text-muted-foreground">
Don&apos;t have an account?{' '}
<Link href="/register" className="text-foreground underline underline-offset-4 hover:text-foreground/80">
Register
</Link>
</p>
</div>
</div>
</div>
)
}
+142
View File
@@ -0,0 +1,142 @@
'use client'
import { Nav } from '@/components/layout/nav'
import { StatusBadge } from '@/components/common/status-badge'
import { ChainIcon } from '@/components/common/chain-icon'
const stats = [
{ label: 'Vaults', value: '--', description: 'Total vaults' },
{ label: 'Wallets', value: '--', description: 'Total wallets' },
{ label: 'Pending TX', value: '--', description: 'Awaiting approval' },
]
const recentTx: {
id: string
type: string
chain: string
amount: string
status: string
created_at: string
}[] = []
const clusterNodes = [
{ id: 'node0', status: 'running', uptime: '99.9%' },
{ id: 'node1', status: 'running', uptime: '99.8%' },
{ id: 'node2', status: 'running', uptime: '99.7%' },
]
export default function DashboardPage() {
return (
<>
<Nav />
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
{/* Portfolio overview */}
<div className="mb-8">
<h1 className="text-2xl font-semibold tracking-tight">Dashboard</h1>
<p className="mt-1 text-sm text-muted-foreground">
Portfolio overview and MPC cluster status.
</p>
</div>
{/* Total value */}
<div className="mb-8 rounded-lg border border-border bg-card p-6">
<p className="text-sm text-muted-foreground">Total Portfolio Value</p>
<p className="mt-1 text-3xl font-semibold tracking-tight font-mono">$--,---.--</p>
</div>
{/* Stats cards */}
<div className="mb-8 grid gap-4 sm:grid-cols-3">
{stats.map((stat) => (
<div key={stat.label} className="rounded-lg border border-border bg-card p-6">
<p className="text-sm text-muted-foreground">{stat.label}</p>
<p className="mt-1 text-2xl font-semibold tracking-tight font-mono">{stat.value}</p>
<p className="mt-1 text-xs text-muted-foreground">{stat.description}</p>
</div>
))}
</div>
{/* Recent transactions */}
<div className="mb-8">
<h2 className="mb-4 text-lg font-semibold">Recent Transactions</h2>
<div className="overflow-x-auto rounded-md border border-border">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-muted/50">
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Type</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Chain</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Amount</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Status</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Date</th>
</tr>
</thead>
<tbody>
{recentTx.length === 0 ? (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-muted-foreground">
No recent transactions.
</td>
</tr>
) : (
recentTx.map((tx) => (
<tr key={tx.id} className="border-b border-border last:border-0">
<td className="px-4 py-3 capitalize">{tx.type}</td>
<td className="px-4 py-3">
<ChainIcon chain={tx.chain} />
</td>
<td className="px-4 py-3 font-mono">{tx.amount}</td>
<td className="px-4 py-3">
<StatusBadge status={tx.status} />
</td>
<td className="px-4 py-3 text-muted-foreground">{tx.created_at}</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* Vault summary */}
<div className="mb-8">
<h2 className="mb-4 text-lg font-semibold">Vault Summary</h2>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div className="rounded-lg border border-border bg-card p-6 text-center text-muted-foreground">
No vaults created yet.
</div>
</div>
</div>
{/* MPC Cluster Status */}
<div>
<h2 className="mb-4 text-lg font-semibold">MPC Cluster Status</h2>
<div className="rounded-lg border border-border bg-card p-6">
<div className="mb-4 flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Threshold</p>
<p className="text-lg font-semibold font-mono">2 of 3</p>
</div>
<StatusBadge status="running" />
</div>
<div className="space-y-3">
{clusterNodes.map((node) => (
<div
key={node.id}
className="flex items-center justify-between rounded-md bg-muted/50 px-4 py-3"
>
<div className="flex items-center gap-3">
<div className="h-2 w-2 rounded-full bg-emerald-400" />
<span className="text-sm font-medium font-mono">{node.id}</span>
</div>
<div className="flex items-center gap-4">
<span className="text-xs text-muted-foreground">Uptime: {node.uptime}</span>
<StatusBadge status={node.status} />
</div>
</div>
))}
</div>
</div>
</div>
</main>
</>
)
}
+230
View File
@@ -0,0 +1,230 @@
'use client'
import { useState, type FormEvent } from 'react'
import Link from 'next/link'
import { Nav } from '@/components/layout/nav'
import { api } from '@/lib/api'
const chains = ['ethereum', 'bitcoin', 'lux', 'solana', 'xrpl']
export default function NewPaymentPage() {
const [merchantName, setMerchantName] = useState('')
const [recipientAddress, setRecipientAddress] = useState('')
const [chain, setChain] = useState('ethereum')
const [amount, setAmount] = useState('')
const [memo, setMemo] = useState('')
const [expiresInHours, setExpiresInHours] = useState(24)
const [error, setError] = useState('')
const [submitting, setSubmitting] = useState(false)
const [paymentUrl, setPaymentUrl] = useState<string | null>(null)
const [copied, setCopied] = useState(false)
async function handleSubmit(e: FormEvent) {
e.preventDefault()
setSubmitting(true)
setError('')
try {
const result = await api.createPaymentRequest({
merchant_name: merchantName || undefined,
recipient_address: recipientAddress,
chain,
amount,
memo: memo || undefined,
expires_in_hours: expiresInHours,
})
setPaymentUrl(result.payment_url)
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to create payment request')
} finally {
setSubmitting(false)
}
}
function handleCopy() {
if (paymentUrl) {
navigator.clipboard.writeText(paymentUrl)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
}
if (paymentUrl) {
return (
<>
<Nav />
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<div className="mx-auto max-w-lg space-y-8">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Payment Request Created</h1>
<p className="mt-1 text-sm text-muted-foreground">Share this link with the payer.</p>
</div>
<div className="rounded-lg border border-border bg-card p-6 space-y-4">
<div className="space-y-1.5">
<label className="text-sm font-medium text-muted-foreground">Payment Link</label>
<div className="flex gap-2">
<input
type="text"
readOnly
value={paymentUrl}
className="flex-1 rounded-md border border-input bg-background px-3 py-2 font-mono text-sm"
/>
<button
onClick={handleCopy}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
{copied ? 'Copied' : 'Copy'}
</button>
</div>
</div>
<dl className="grid grid-cols-2 gap-3 text-sm">
<div>
<dt className="text-muted-foreground">Amount</dt>
<dd className="mt-1 font-mono">{amount}</dd>
</div>
<div>
<dt className="text-muted-foreground">Chain</dt>
<dd className="mt-1 capitalize">{chain}</dd>
</div>
<div>
<dt className="text-muted-foreground">Expires</dt>
<dd className="mt-1">{expiresInHours}h</dd>
</div>
{merchantName && (
<div>
<dt className="text-muted-foreground">Merchant</dt>
<dd className="mt-1">{merchantName}</dd>
</div>
)}
</dl>
</div>
<div className="flex gap-3">
<button
onClick={() => {
setPaymentUrl(null)
setMerchantName('')
setRecipientAddress('')
setAmount('')
setMemo('')
}}
className="rounded-md border border-border px-4 py-2 text-sm font-medium text-muted-foreground hover:bg-accent"
>
Create Another
</button>
<Link
href="/payments"
className="rounded-md border border-border px-4 py-2 text-sm font-medium text-muted-foreground hover:bg-accent"
>
Back to Payments
</Link>
</div>
</div>
</main>
</>
)
}
return (
<>
<Nav />
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<div className="mb-8 flex items-center gap-2 text-sm text-muted-foreground">
<Link href="/payments" className="hover:text-foreground">Payments</Link>
<span>/</span>
<span>New</span>
</div>
<h1 className="mb-8 text-2xl font-semibold tracking-tight">Create Payment Request</h1>
<div className="mx-auto max-w-lg">
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1.5">
<label className="text-sm font-medium text-muted-foreground">Merchant Name</label>
<input
type="text"
value={merchantName}
onChange={(e) => setMerchantName(e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="Your business name"
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-muted-foreground">Recipient Address</label>
<input
type="text"
required
value={recipientAddress}
onChange={(e) => setRecipientAddress(e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="0x..."
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<label className="text-sm font-medium text-muted-foreground">Chain</label>
<select
value={chain}
onChange={(e) => setChain(e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
{chains.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-muted-foreground">Amount</label>
<input
type="text"
required
value={amount}
onChange={(e) => setAmount(e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="100.00"
/>
</div>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-muted-foreground">Memo</label>
<textarea
value={memo}
onChange={(e) => setMemo(e.target.value)}
rows={2}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="Optional note for the payer"
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-muted-foreground">Expiry (hours)</label>
<input
type="number"
min={1}
max={720}
value={expiresInHours}
onChange={(e) => setExpiresInHours(Number(e.target.value))}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<button
type="submit"
disabled={submitting || !recipientAddress || !amount}
className="w-full rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{submitting ? 'Creating...' : 'Create Payment Request'}
</button>
</form>
</div>
</main>
</>
)
}
+73
View File
@@ -0,0 +1,73 @@
'use client'
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { Nav } from '@/components/layout/nav'
import { StatusBadge } from '@/components/common/status-badge'
import { api } from '@/lib/api'
import type { PaymentRequest } from '@/lib/types'
export default function PaymentsPage() {
const [payments, setPayments] = useState<PaymentRequest[]>([])
const [error, setError] = useState('')
useEffect(() => {
api.listPaymentRequests().then(setPayments).catch((e) => setError(e.message))
}, [])
return (
<>
<Nav />
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<div className="mb-8 flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Payment Requests</h1>
<p className="mt-1 text-sm text-muted-foreground">Create and manage payment requests.</p>
</div>
<Link
href="/payments/new"
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
Create Payment Request
</Link>
</div>
{error && <p className="mb-4 text-sm text-destructive">{error}</p>}
<div className="overflow-x-auto rounded-lg border border-border">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-muted/50">
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Merchant</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Amount</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Chain</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Status</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Created</th>
</tr>
</thead>
<tbody>
{payments.map((pr) => (
<tr key={pr.id} className="border-b border-border last:border-0 hover:bg-muted/30">
<td className="px-4 py-3 font-medium">{pr.merchant_name || '--'}</td>
<td className="px-4 py-3 font-mono">{pr.amount} {pr.token || ''}</td>
<td className="px-4 py-3 capitalize">{pr.chain}</td>
<td className="px-4 py-3"><StatusBadge status={pr.status} /></td>
<td className="px-4 py-3 text-muted-foreground">
{new Date(pr.created_at).toLocaleDateString()}
</td>
</tr>
))}
{payments.length === 0 && (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-muted-foreground">
No payment requests.
</td>
</tr>
)}
</tbody>
</table>
</div>
</main>
</>
)
}
+196
View File
@@ -0,0 +1,196 @@
'use client'
import { useState, useEffect, type FormEvent } from 'react'
import { Nav } from '@/components/layout/nav'
import { StatusBadge } from '@/components/common/status-badge'
import { api } from '@/lib/api'
import type { Policy } from '@/lib/types'
const actionOptions = ['approve', 'deny', 'require_approval']
export default function PoliciesPage() {
const [policies, setPolicies] = useState<Policy[]>([])
const [error, setError] = useState('')
// Create form state
const [name, setName] = useState('')
const [action, setAction] = useState('require_approval')
const [priority, setPriority] = useState(0)
const [requiredApprovers, setRequiredApprovers] = useState(1)
const [approverRoles, setApproverRoles] = useState('admin,owner')
const [creating, setCreating] = useState(false)
useEffect(() => {
api.listPolicies().then(setPolicies).catch((e) => setError(e.message))
}, [])
async function handleCreate(e: FormEvent) {
e.preventDefault()
setCreating(true)
setError('')
try {
const policy = await api.createPolicy({
name,
action,
priority,
conditions: {},
required_approvers: requiredApprovers,
approver_roles: approverRoles.split(',').map((r) => r.trim()).filter(Boolean),
})
setPolicies((prev) => [policy, ...prev])
setName('')
setPriority(0)
setRequiredApprovers(1)
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to create policy')
} finally {
setCreating(false)
}
}
async function handleDelete(id: string) {
try {
await api.deletePolicy(id)
setPolicies((prev) => prev.filter((p) => p.id !== id))
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to delete policy')
}
}
async function handleToggle(id: string, enabled: boolean) {
try {
const updated = await api.updatePolicy(id, { enabled: !enabled })
setPolicies((prev) => prev.map((p) => (p.id === id ? updated : p)))
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to update policy')
}
}
return (
<>
<Nav />
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<div className="mb-8">
<h1 className="text-2xl font-semibold tracking-tight">Policies</h1>
<p className="mt-1 text-sm text-muted-foreground">Configure signing policies and approval rules.</p>
</div>
{error && <p className="mb-4 text-sm text-destructive">{error}</p>}
{/* Inline create form */}
<form onSubmit={handleCreate} className="mb-8 rounded-lg border border-border bg-card p-6 space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">Create Policy</h2>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div className="space-y-1.5">
<label className="text-sm font-medium text-muted-foreground">Name</label>
<input
type="text"
required
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="Daily spend limit"
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-muted-foreground">Action</label>
<select
value={action}
onChange={(e) => setAction(e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
{actionOptions.map((a) => (
<option key={a} value={a}>{a.replace(/_/g, ' ')}</option>
))}
</select>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-muted-foreground">Priority</label>
<input
type="number"
value={priority}
onChange={(e) => setPriority(Number(e.target.value))}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-muted-foreground">Required Approvers</label>
<input
type="number"
min={1}
value={requiredApprovers}
onChange={(e) => setRequiredApprovers(Number(e.target.value))}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-muted-foreground">Approver Roles (comma-separated)</label>
<input
type="text"
value={approverRoles}
onChange={(e) => setApproverRoles(e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="admin,owner"
/>
</div>
<button
type="submit"
disabled={creating || !name.trim()}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{creating ? 'Creating...' : 'Create Policy'}
</button>
</form>
{/* Table */}
<div className="overflow-x-auto rounded-lg border border-border">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-muted/50">
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Name</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Action</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Priority</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Status</th>
<th className="px-4 py-3 text-right font-medium text-muted-foreground">Actions</th>
</tr>
</thead>
<tbody>
{policies.map((p) => (
<tr key={p.id} className="border-b border-border last:border-0">
<td className="px-4 py-3 font-medium">{p.name}</td>
<td className="px-4 py-3 capitalize">{p.action.replace(/_/g, ' ')}</td>
<td className="px-4 py-3 font-mono">{p.priority}</td>
<td className="px-4 py-3">
<StatusBadge status={p.enabled ? 'enabled' : 'disabled'} />
</td>
<td className="px-4 py-3 text-right space-x-3">
<button
onClick={() => handleToggle(p.id, p.enabled)}
className="text-sm text-muted-foreground hover:text-foreground"
>
{p.enabled ? 'Disable' : 'Enable'}
</button>
<button
onClick={() => handleDelete(p.id)}
className="text-sm text-destructive hover:underline"
>
Delete
</button>
</td>
</tr>
))}
{policies.length === 0 && (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-muted-foreground">
No policies configured.
</td>
</tr>
)}
</tbody>
</table>
</div>
</main>
</>
)
}
+24
View File
@@ -0,0 +1,24 @@
'use client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useState, type ReactNode } from 'react'
export function Providers({ children }: { children: ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 30 * 1000,
retry: 1,
},
},
})
)
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
}
+111
View File
@@ -0,0 +1,111 @@
'use client'
import { useState, type FormEvent } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { api, APIError } from '@/lib/api'
import { setTokens } from '@/lib/auth'
export default function RegisterPage() {
const router = useRouter()
const [orgName, setOrgName] = useState('')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
async function handleSubmit(e: FormEvent) {
e.preventDefault()
setError('')
setLoading(true)
try {
const data = await api.register({ org_name: orgName, email, password })
setTokens(data.access_token, data.refresh_token)
router.push('/')
} catch (err) {
setError(err instanceof APIError ? err.message : 'Registration failed')
} finally {
setLoading(false)
}
}
return (
<div className="flex min-h-screen items-center justify-center px-4">
<div className="w-full max-w-sm">
<div className="rounded-lg border border-border bg-card p-8">
<h1 className="mb-6 text-center text-xl font-semibold tracking-tight">
Create Account
</h1>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="org" className="mb-1.5 block text-sm font-medium text-muted-foreground">
Organization Name
</label>
<input
id="org"
type="text"
required
value={orgName}
onChange={(e) => setOrgName(e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="Acme Inc."
/>
</div>
<div>
<label htmlFor="email" className="mb-1.5 block text-sm font-medium text-muted-foreground">
Email
</label>
<input
id="email"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="[email protected]"
/>
</div>
<div>
<label htmlFor="password" className="mb-1.5 block text-sm font-medium text-muted-foreground">
Password
</label>
<input
id="password"
type="password"
required
minLength={8}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="Min 8 characters"
/>
</div>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
<button
type="submit"
disabled={loading}
className="w-full rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
>
{loading ? 'Creating account...' : 'Create account'}
</button>
</form>
<p className="mt-4 text-center text-sm text-muted-foreground">
Already have an account?{' '}
<Link href="/login" className="text-foreground underline underline-offset-4 hover:text-foreground/80">
Login
</Link>
</p>
</div>
</div>
</div>
)
}
+195
View File
@@ -0,0 +1,195 @@
'use client'
import { useState, useEffect, type FormEvent } from 'react'
import Link from 'next/link'
import { Nav } from '@/components/layout/nav'
import { api } from '@/lib/api'
import type { APIKey, APIKeyCreateResponse } from '@/lib/types'
const permissionOptions = ['read', 'write', 'sign', 'admin']
export default function ApiKeysPage() {
const [keys, setKeys] = useState<APIKey[]>([])
const [error, setError] = useState('')
// Create form
const [name, setName] = useState('')
const [permissions, setPermissions] = useState<string[]>(['read'])
const [creating, setCreating] = useState(false)
const [newKey, setNewKey] = useState<string | null>(null)
const [copied, setCopied] = useState(false)
useEffect(() => {
api.listAPIKeys().then(setKeys).catch((e) => setError(e.message))
}, [])
function togglePermission(perm: string) {
setPermissions((prev) =>
prev.includes(perm) ? prev.filter((p) => p !== perm) : [...prev, perm]
)
}
async function handleCreate(e: FormEvent) {
e.preventDefault()
if (!name.trim()) return
setCreating(true)
setError('')
try {
const result: APIKeyCreateResponse = await api.createAPIKey({ name, permissions })
setNewKey(result.key)
setKeys((prev) => [result, ...prev])
setName('')
setPermissions(['read'])
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to create key')
} finally {
setCreating(false)
}
}
function handleCopy() {
if (newKey) {
navigator.clipboard.writeText(newKey)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
}
async function handleDelete(id: string) {
try {
await api.deleteAPIKey(id)
setKeys((prev) => prev.filter((k) => k.id !== id))
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to delete key')
}
}
return (
<>
<Nav />
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<div className="mb-2 flex items-center gap-2 text-sm text-muted-foreground">
<Link href="/settings" className="hover:text-foreground">Settings</Link>
<span>/</span>
<span>API Keys</span>
</div>
<div className="mb-8">
<h1 className="text-2xl font-semibold tracking-tight">API Keys</h1>
<p className="mt-1 text-sm text-muted-foreground">Manage programmatic access keys.</p>
</div>
{error && <p className="mb-4 text-sm text-destructive">{error}</p>}
{/* Created key banner */}
{newKey && (
<div className="mb-6 rounded-lg border border-amber-500/20 bg-amber-500/5 p-4">
<p className="mb-2 text-sm font-medium text-amber-400">
API key created. Copy it now -- it will not be shown again.
</p>
<div className="flex items-center gap-2">
<code className="flex-1 break-all rounded-md bg-muted p-2 font-mono text-xs">{newKey}</code>
<button
onClick={handleCopy}
className="shrink-0 rounded-md border border-border px-3 py-1.5 text-xs font-medium transition-colors hover:bg-accent"
>
{copied ? 'Copied' : 'Copy'}
</button>
</div>
<button
onClick={() => setNewKey(null)}
className="mt-2 text-xs text-muted-foreground hover:text-foreground"
>
Dismiss
</button>
</div>
)}
{/* Create form */}
<form onSubmit={handleCreate} className="mb-8 rounded-lg border border-border bg-card p-6 space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">Create Key</h2>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<label className="text-sm font-medium text-muted-foreground">Name</label>
<input
type="text"
required
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="Production API key"
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-muted-foreground">Permissions</label>
<div className="flex flex-wrap gap-3">
{permissionOptions.map((perm) => (
<label key={perm} className="flex items-center gap-1.5">
<input
type="checkbox"
checked={permissions.includes(perm)}
onChange={() => togglePermission(perm)}
className="rounded border-input"
/>
<span className="text-sm capitalize">{perm}</span>
</label>
))}
</div>
</div>
</div>
<button
type="submit"
disabled={creating || !name.trim()}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{creating ? 'Creating...' : 'Create Key'}
</button>
</form>
{/* Table */}
<div className="overflow-x-auto rounded-lg border border-border">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-muted/50">
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Name</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Key Prefix</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Created</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Last Used</th>
<th className="px-4 py-3 text-right font-medium text-muted-foreground">Actions</th>
</tr>
</thead>
<tbody>
{keys.map((key) => (
<tr key={key.id} className="border-b border-border last:border-0">
<td className="px-4 py-3 font-medium">{key.name}</td>
<td className="px-4 py-3 font-mono text-xs">{key.key_prefix}...</td>
<td className="px-4 py-3 text-muted-foreground">
{new Date(key.created_at).toLocaleDateString()}
</td>
<td className="px-4 py-3 text-muted-foreground">
{key.last_used_at ? new Date(key.last_used_at).toLocaleDateString() : 'Never'}
</td>
<td className="px-4 py-3 text-right">
<button
onClick={() => handleDelete(key.id)}
className="text-sm text-destructive hover:underline"
>
Revoke
</button>
</td>
</tr>
))}
{keys.length === 0 && (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-muted-foreground">
No API keys created.
</td>
</tr>
)}
</tbody>
</table>
</div>
</main>
</>
)
}
+87
View File
@@ -0,0 +1,87 @@
'use client'
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { Nav } from '@/components/layout/nav'
import { api } from '@/lib/api'
import type { InfoResponse } from '@/lib/types'
const settingsLinks = [
{
href: '/settings/api-keys' as const,
title: 'API Keys',
description: 'Manage API keys for programmatic access.',
},
{
href: '/settings/webhooks' as const,
title: 'Webhooks',
description: 'Configure webhook endpoints for event notifications.',
},
]
export default function SettingsPage() {
const [info, setInfo] = useState<InfoResponse | null>(null)
useEffect(() => {
api.getInfo().then(setInfo).catch(() => {})
}, [])
return (
<>
<Nav />
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<div className="mb-8">
<h1 className="text-2xl font-semibold tracking-tight">Settings</h1>
<p className="mt-1 text-sm text-muted-foreground">Organization settings and integrations.</p>
</div>
{/* Org info */}
<div className="mb-8 rounded-lg border border-border bg-card p-6">
<h2 className="mb-4 text-lg font-semibold">Organization</h2>
<dl className="grid gap-4 sm:grid-cols-2">
<div>
<dt className="text-xs text-muted-foreground">Service</dt>
<dd className="mt-1 text-sm font-medium">{info?.name || '--'}</dd>
</div>
<div>
<dt className="text-xs text-muted-foreground">Version</dt>
<dd className="mt-1 font-mono text-sm">{info?.version || '--'}</dd>
</div>
<div>
<dt className="text-xs text-muted-foreground">Supported Chains</dt>
<dd className="mt-1 text-sm">
{info?.supported_chains?.join(', ') || '--'}
</dd>
</div>
<div>
<dt className="text-xs text-muted-foreground">Key Types</dt>
<dd className="mt-1 font-mono text-sm">
{info?.key_types?.join(', ') || '--'}
</dd>
</div>
<div>
<dt className="text-xs text-muted-foreground">Protocols</dt>
<dd className="mt-1 text-sm">
{info?.protocols?.join(', ') || '--'}
</dd>
</div>
</dl>
</div>
{/* Sub-pages */}
<div className="grid gap-4 sm:grid-cols-2">
{settingsLinks.map((link) => (
<Link
key={link.href}
href={link.href}
className="group rounded-lg border border-border bg-card p-6 transition-colors hover:border-foreground/20"
>
<h3 className="font-semibold group-hover:text-foreground">{link.title}</h3>
<p className="mt-1 text-sm text-muted-foreground">{link.description}</p>
</Link>
))}
</div>
</main>
</>
)
}
+222
View File
@@ -0,0 +1,222 @@
'use client'
import { useState, useEffect, type FormEvent } from 'react'
import Link from 'next/link'
import { Nav } from '@/components/layout/nav'
import { StatusBadge } from '@/components/common/status-badge'
import { api } from '@/lib/api'
import type { Webhook } from '@/lib/types'
const eventOptions = [
'transaction.created',
'transaction.approved',
'transaction.signed',
'transaction.broadcast',
'transaction.failed',
'wallet.created',
'wallet.reshared',
'policy.triggered',
]
export default function WebhooksPage() {
const [webhooks, setWebhooks] = useState<Webhook[]>([])
const [error, setError] = useState('')
// Create form
const [url, setUrl] = useState('')
const [secret, setSecret] = useState('')
const [selectedEvents, setSelectedEvents] = useState<string[]>([])
const [creating, setCreating] = useState(false)
const [testing, setTesting] = useState<string | null>(null)
useEffect(() => {
api.listWebhooks().then(setWebhooks).catch((e) => setError(e.message))
}, [])
function toggleEvent(event: string) {
setSelectedEvents((prev) =>
prev.includes(event) ? prev.filter((e) => e !== event) : [...prev, event]
)
}
async function handleCreate(e: FormEvent) {
e.preventDefault()
if (!url.trim() || selectedEvents.length === 0 || !secret.trim()) return
setCreating(true)
setError('')
try {
const webhook = await api.createWebhook({ url, events: selectedEvents, secret })
setWebhooks((prev) => [webhook, ...prev])
setUrl('')
setSecret('')
setSelectedEvents([])
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to create webhook')
} finally {
setCreating(false)
}
}
async function handleTest(id: string) {
setTesting(id)
setError('')
try {
await api.testWebhook(id)
} catch (e) {
setError(e instanceof Error ? e.message : 'Test failed')
} finally {
setTesting(null)
}
}
async function handleDelete(id: string) {
try {
await api.deleteWebhook(id)
setWebhooks((prev) => prev.filter((w) => w.id !== id))
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to delete webhook')
}
}
async function handleToggle(id: string, enabled: boolean) {
try {
const updated = await api.updateWebhook(id, { enabled: !enabled })
setWebhooks((prev) => prev.map((w) => (w.id === id ? updated : w)))
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to update webhook')
}
}
return (
<>
<Nav />
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<div className="mb-2 flex items-center gap-2 text-sm text-muted-foreground">
<Link href="/settings" className="hover:text-foreground">Settings</Link>
<span>/</span>
<span>Webhooks</span>
</div>
<div className="mb-8">
<h1 className="text-2xl font-semibold tracking-tight">Webhooks</h1>
<p className="mt-1 text-sm text-muted-foreground">Receive event notifications via HTTP callbacks.</p>
</div>
{error && <p className="mb-4 text-sm text-destructive">{error}</p>}
{/* Create form */}
<form onSubmit={handleCreate} className="mb-8 rounded-lg border border-border bg-card p-6 space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">Create Webhook</h2>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<label className="text-sm font-medium text-muted-foreground">URL</label>
<input
type="url"
required
value={url}
onChange={(e) => setUrl(e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="https://api.example.com/webhook"
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-muted-foreground">Secret</label>
<input
type="text"
required
value={secret}
onChange={(e) => setSecret(e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="whsec_..."
/>
</div>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-muted-foreground">Events</label>
<div className="flex flex-wrap gap-3">
{eventOptions.map((event) => (
<label key={event} className="flex items-center gap-1.5">
<input
type="checkbox"
checked={selectedEvents.includes(event)}
onChange={() => toggleEvent(event)}
className="rounded border-input"
/>
<span className="text-sm">{event}</span>
</label>
))}
</div>
</div>
<button
type="submit"
disabled={creating || !url.trim() || selectedEvents.length === 0 || !secret.trim()}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{creating ? 'Creating...' : 'Create Webhook'}
</button>
</form>
{/* Table */}
<div className="overflow-x-auto rounded-lg border border-border">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-muted/50">
<th className="px-4 py-3 text-left font-medium text-muted-foreground">URL</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Events</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Status</th>
<th className="px-4 py-3 text-right font-medium text-muted-foreground">Actions</th>
</tr>
</thead>
<tbody>
{webhooks.map((wh) => (
<tr key={wh.id} className="border-b border-border last:border-0">
<td className="px-4 py-3 font-mono text-xs break-all max-w-[300px]">{wh.url}</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-1">
{wh.events.map((ev) => (
<span key={ev} className="rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">
{ev}
</span>
))}
</div>
</td>
<td className="px-4 py-3">
<StatusBadge status={wh.enabled ? 'active' : 'disabled'} />
</td>
<td className="px-4 py-3 text-right space-x-3">
<button
onClick={() => handleTest(wh.id)}
disabled={testing === wh.id}
className="text-sm text-muted-foreground hover:text-foreground disabled:opacity-50"
>
{testing === wh.id ? 'Testing...' : 'Test'}
</button>
<button
onClick={() => handleToggle(wh.id, wh.enabled)}
className="text-sm text-muted-foreground hover:text-foreground"
>
{wh.enabled ? 'Disable' : 'Enable'}
</button>
<button
onClick={() => handleDelete(wh.id)}
className="text-sm text-destructive hover:underline"
>
Delete
</button>
</td>
</tr>
))}
{webhooks.length === 0 && (
<tr>
<td colSpan={4} className="px-4 py-8 text-center text-muted-foreground">
No webhooks configured.
</td>
</tr>
)}
</tbody>
</table>
</div>
</main>
</>
)
}
+242
View File
@@ -0,0 +1,242 @@
'use client'
import { useState } from 'react'
import { useParams } from 'next/navigation'
import { SafeTxQueue } from '@/components/smart-wallets/safe-tx-queue'
import { TxTable } from '@/components/transactions/tx-table'
interface SmartWalletDetail {
id: string
type: 'safe' | 'erc4337'
chain: string
chainId: number
contractAddress: string
threshold: number
owners: string[]
mpcWalletId: string
}
interface PendingSafeTx {
id: string
to: string
value: string
data: string
nonce: number
confirmations: number
required: number
submittedBy: string
createdAt: string
}
export default function SmartWalletDetailPage() {
const params = useParams<{ id: string }>()
// TODO: fetch from API
const [wallet] = useState<SmartWalletDetail>({
id: params.id,
type: 'safe',
chain: 'Ethereum',
chainId: 1,
contractAddress: '0x1234567890abcdef1234567890abcdef12345678',
threshold: 2,
owners: [
'0xaaa1111111111111111111111111111111111111',
'0xbbb2222222222222222222222222222222222222',
'0xccc3333333333333333333333333333333333333',
],
mpcWalletId: 'wallet-abc',
})
const [pendingTxs] = useState<PendingSafeTx[]>([
{
id: 'stx-1',
to: '0xddd4444444444444444444444444444444444444',
value: '1.5 ETH',
data: '0x',
nonce: 0,
confirmations: 1,
required: 2,
submittedBy: '0xaaa...111',
createdAt: '2026-02-28T10:00:00Z',
},
])
// Safe: propose new transaction
const [proposeTo, setProposeTo] = useState('')
const [proposeValue, setProposeValue] = useState('')
const [proposeData, setProposeData] = useState('')
// ERC-4337: submit UserOperation
const [userOpTarget, setUserOpTarget] = useState('')
const [userOpValue, setUserOpValue] = useState('')
const [userOpCalldata, setUserOpCalldata] = useState('')
async function handleProposeSafeTx() {
// TODO: call API to propose Safe transaction
alert(`Propose tx to ${proposeTo} for ${proposeValue}`)
}
async function handleSubmitUserOp() {
// TODO: call API to submit UserOperation
alert(`Submit UserOp to ${userOpTarget} for ${userOpValue}`)
}
return (
<div className="mx-auto max-w-4xl space-y-8 px-4 py-8">
<div>
<h1 className="text-2xl font-semibold tracking-tight">
{wallet.type === 'safe' ? 'Safe' : 'ERC-4337'} Wallet
</h1>
<p className="mt-1 truncate font-mono text-sm text-muted-foreground">
{wallet.contractAddress}
</p>
</div>
{/* Contract info */}
<section className="rounded-lg border border-border bg-card p-6 space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
Contract Info
</h2>
<dl className="grid grid-cols-2 gap-4 text-sm">
<div>
<dt className="text-muted-foreground">Type</dt>
<dd className="mt-1">
{wallet.type === 'safe' ? 'Gnosis Safe' : 'ERC-4337 Account'}
</dd>
</div>
<div>
<dt className="text-muted-foreground">Chain</dt>
<dd className="mt-1">
{wallet.chain} ({wallet.chainId})
</dd>
</div>
<div>
<dt className="text-muted-foreground">Threshold</dt>
<dd className="mt-1 font-mono">
{wallet.threshold}-of-{wallet.owners.length}
</dd>
</div>
<div>
<dt className="text-muted-foreground">MPC Wallet</dt>
<dd className="mt-1 font-mono text-xs">{wallet.mpcWalletId}</dd>
</div>
<div className="col-span-2">
<dt className="text-muted-foreground">Owners</dt>
<dd className="mt-1 space-y-1">
{wallet.owners.map((o) => (
<p key={o} className="truncate font-mono text-xs">
{o}
</p>
))}
</dd>
</div>
</dl>
</section>
{/* Safe: pending transactions */}
{wallet.type === 'safe' && (
<>
<section className="space-y-4">
<h2 className="text-lg font-semibold">Pending Transactions</h2>
<SafeTxQueue transactions={pendingTxs} walletId={wallet.id} />
</section>
<section className="rounded-lg border border-border bg-card p-6 space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
Propose Transaction
</h2>
<div className="space-y-3">
<input
type="text"
value={proposeTo}
onChange={(e) => setProposeTo(e.target.value)}
placeholder="Recipient address (0x...)"
className="w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
<input
type="text"
value={proposeValue}
onChange={(e) => setProposeValue(e.target.value)}
placeholder="Value (e.g. 1.0)"
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
<textarea
value={proposeData}
onChange={(e) => setProposeData(e.target.value)}
placeholder="Calldata (0x... or leave empty)"
rows={3}
className="w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
<button
onClick={handleProposeSafeTx}
disabled={!proposeTo}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
Propose Transaction
</button>
</div>
</section>
</>
)}
{/* ERC-4337: UserOperation */}
{wallet.type === 'erc4337' && (
<section className="rounded-lg border border-border bg-card p-6 space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
Submit UserOperation
</h2>
<div className="space-y-3">
<input
type="text"
value={userOpTarget}
onChange={(e) => setUserOpTarget(e.target.value)}
placeholder="Target address (0x...)"
className="w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
<input
type="text"
value={userOpValue}
onChange={(e) => setUserOpValue(e.target.value)}
placeholder="Value (e.g. 1.0)"
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
<textarea
value={userOpCalldata}
onChange={(e) => setUserOpCalldata(e.target.value)}
placeholder="Calldata (0x...)"
rows={3}
className="w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
<button
onClick={handleSubmitUserOp}
disabled={!userOpTarget}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
Submit UserOperation
</button>
</div>
</section>
)}
{/* Transaction history */}
<section className="space-y-4">
<h2 className="text-lg font-semibold">Transaction History</h2>
<TxTable
transactions={[
{
id: 'tx-1',
type: 'send',
status: 'confirmed',
chain: wallet.chain,
from: wallet.contractAddress,
to: '0xddd...444',
value: '0.5 ETH',
hash: '0xabc...123',
timestamp: '2026-02-27T15:30:00Z',
},
]}
/>
</section>
</div>
)
}
+189
View File
@@ -0,0 +1,189 @@
'use client'
import { useState, useEffect } from 'react'
import { useParams } from 'next/navigation'
import Link from 'next/link'
import { Nav } from '@/components/layout/nav'
import { StatusBadge } from '@/components/common/status-badge'
import { api } from '@/lib/api'
import type { Subscription } from '@/lib/types'
export default function SubscriptionDetailPage() {
const { id } = useParams<{ id: string }>()
const [sub, setSub] = useState<Subscription | null>(null)
const [error, setError] = useState('')
const [acting, setActing] = useState(false)
useEffect(() => {
if (!id) return
api.getSubscription(id).then(setSub).catch((e) => setError(e.message))
}, [id])
async function handlePayNow() {
setActing(true)
setError('')
try {
await api.payNow(id)
const updated = await api.getSubscription(id)
setSub(updated)
} catch (e) {
setError(e instanceof Error ? e.message : 'Payment failed')
} finally {
setActing(false)
}
}
async function handleStatusChange(status: string) {
setActing(true)
setError('')
try {
const updated = await api.updateSubscription(id, { status })
setSub(updated)
} catch (e) {
setError(e instanceof Error ? e.message : 'Update failed')
} finally {
setActing(false)
}
}
async function handleCancel() {
setActing(true)
setError('')
try {
await api.deleteSubscription(id)
const updated = await api.getSubscription(id)
setSub(updated)
} catch (e) {
setError(e instanceof Error ? e.message : 'Cancellation failed')
} finally {
setActing(false)
}
}
return (
<>
<Nav />
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<div className="mb-8 flex items-center gap-2 text-sm text-muted-foreground">
<Link href="/subscriptions" className="hover:text-foreground">Subscriptions</Link>
<span>/</span>
<span>{sub?.name || 'Detail'}</span>
</div>
{error && <p className="mb-4 text-sm text-destructive">{error}</p>}
{!sub && !error && <p className="text-sm text-muted-foreground">Loading...</p>}
{sub && (
<>
<div className="mb-8 flex items-start justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">{sub.name}</h1>
{sub.provider_name && (
<p className="mt-1 text-sm text-muted-foreground">{sub.provider_name}</p>
)}
</div>
<StatusBadge status={sub.status} />
</div>
{/* Info */}
<div className="mb-8 rounded-lg border border-border bg-card p-6">
<h2 className="mb-4 text-sm font-medium uppercase tracking-wider text-muted-foreground">
Subscription Details
</h2>
<dl className="grid grid-cols-2 gap-4 text-sm sm:grid-cols-3">
<div>
<dt className="text-muted-foreground">Amount</dt>
<dd className="mt-1 font-mono">{sub.amount} {sub.token || sub.currency}</dd>
</div>
<div>
<dt className="text-muted-foreground">Interval</dt>
<dd className="mt-1 capitalize">{sub.interval}</dd>
</div>
<div>
<dt className="text-muted-foreground">Chain</dt>
<dd className="mt-1 capitalize">{sub.chain}</dd>
</div>
{sub.wallet_id && (
<div>
<dt className="text-muted-foreground">Wallet</dt>
<dd className="mt-1">
<Link href={`/wallets/${sub.wallet_id}`} className="font-mono text-xs hover:underline">
{sub.wallet_id.slice(0, 12)}...
</Link>
</dd>
</div>
)}
<div className="col-span-2">
<dt className="text-muted-foreground">Recipient</dt>
<dd className="mt-1 font-mono text-xs break-all">{sub.recipient_address}</dd>
</div>
<div>
<dt className="text-muted-foreground">Next Payment</dt>
<dd className="mt-1">{new Date(sub.next_payment_at).toLocaleDateString()}</dd>
</div>
{sub.last_payment_at && (
<div>
<dt className="text-muted-foreground">Last Payment</dt>
<dd className="mt-1">{new Date(sub.last_payment_at).toLocaleDateString()}</dd>
</div>
)}
<div>
<dt className="text-muted-foreground">Created</dt>
<dd className="mt-1">{new Date(sub.created_at).toLocaleDateString()}</dd>
</div>
<div>
<dt className="text-muted-foreground">Require Balance</dt>
<dd className="mt-1">{sub.require_balance ? 'Yes' : 'No'}</dd>
</div>
<div>
<dt className="text-muted-foreground">Retries</dt>
<dd className="mt-1">{sub.retry_count} / {sub.max_retries}</dd>
</div>
</dl>
</div>
{/* Actions */}
<div className="flex flex-wrap gap-3">
{sub.status === 'active' && (
<>
<button
onClick={handlePayNow}
disabled={acting}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{acting ? 'Processing...' : 'Pay Now'}
</button>
<button
onClick={() => handleStatusChange('paused')}
disabled={acting}
className="rounded-md border border-border px-4 py-2 text-sm font-medium text-muted-foreground hover:bg-accent disabled:opacity-50"
>
Pause
</button>
</>
)}
{sub.status === 'paused' && (
<button
onClick={() => handleStatusChange('active')}
disabled={acting}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
Resume
</button>
)}
{sub.status !== 'cancelled' && (
<button
onClick={handleCancel}
disabled={acting}
className="rounded-md border border-destructive/30 px-4 py-2 text-sm font-medium text-destructive hover:bg-destructive/10 disabled:opacity-50"
>
Cancel Subscription
</button>
)}
</div>
</>
)}
</main>
</>
)
}
+195
View File
@@ -0,0 +1,195 @@
'use client'
import { useState, useEffect, type FormEvent } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { Nav } from '@/components/layout/nav'
import { api } from '@/lib/api'
import type { Wallet, Vault } from '@/lib/types'
const chains = ['ethereum', 'bitcoin', 'lux', 'solana', 'xrpl']
const intervals = ['daily', 'weekly', 'monthly', 'yearly']
export default function NewSubscriptionPage() {
const router = useRouter()
const [wallets, setWallets] = useState<Wallet[]>([])
const [name, setName] = useState('')
const [walletId, setWalletId] = useState('')
const [recipientAddress, setRecipientAddress] = useState('')
const [chain, setChain] = useState('ethereum')
const [token, setToken] = useState('')
const [amount, setAmount] = useState('')
const [interval, setInterval] = useState('monthly')
const [requireBalance, setRequireBalance] = useState(true)
const [error, setError] = useState('')
const [submitting, setSubmitting] = useState(false)
useEffect(() => {
api.listVaults().then(async (vaults: Vault[]) => {
const all: Wallet[] = []
for (const v of vaults) {
const ws = await api.listWallets(v.id)
all.push(...ws)
}
setWallets(all)
if (all.length > 0) setWalletId(all[0].id)
}).catch(() => {})
}, [])
async function handleSubmit(e: FormEvent) {
e.preventDefault()
setSubmitting(true)
setError('')
try {
const sub = await api.createSubscription({
wallet_id: walletId,
name,
recipient_address: recipientAddress,
chain,
token: token || undefined,
amount,
interval,
require_balance: requireBalance,
})
router.push(`/subscriptions/${sub.id}`)
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to create subscription')
} finally {
setSubmitting(false)
}
}
return (
<>
<Nav />
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<div className="mb-8 flex items-center gap-2 text-sm text-muted-foreground">
<Link href="/subscriptions" className="hover:text-foreground">Subscriptions</Link>
<span>/</span>
<span>New</span>
</div>
<h1 className="mb-8 text-2xl font-semibold tracking-tight">Create Subscription</h1>
<div className="mx-auto max-w-lg">
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1.5">
<label className="text-sm font-medium text-muted-foreground">Name</label>
<input
type="text"
required
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="Cloud Hosting"
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-muted-foreground">Wallet</label>
<select
required
value={walletId}
onChange={(e) => setWalletId(e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="">Select wallet...</option>
{wallets.map((w) => (
<option key={w.id} value={w.id}>
{w.name || w.wallet_id} ({w.key_type})
</option>
))}
</select>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-muted-foreground">Recipient Address</label>
<input
type="text"
required
value={recipientAddress}
onChange={(e) => setRecipientAddress(e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="0x..."
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<label className="text-sm font-medium text-muted-foreground">Chain</label>
<select
value={chain}
onChange={(e) => setChain(e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
{chains.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-muted-foreground">Token (optional)</label>
<input
type="text"
value={token}
onChange={(e) => setToken(e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="USDC"
/>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<label className="text-sm font-medium text-muted-foreground">Amount</label>
<input
type="text"
required
value={amount}
onChange={(e) => setAmount(e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="100.00"
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-muted-foreground">Interval</label>
<select
value={interval}
onChange={(e) => setInterval(e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
{intervals.map((i) => (
<option key={i} value={i}>{i}</option>
))}
</select>
</div>
</div>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={requireBalance}
onChange={(e) => setRequireBalance(e.target.checked)}
className="rounded border-input"
/>
<span className="text-sm text-muted-foreground">Require sufficient balance before payment</span>
</label>
{error && <p className="text-sm text-destructive">{error}</p>}
<button
type="submit"
disabled={submitting || !name || !walletId || !recipientAddress || !amount}
className="w-full rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{submitting ? 'Creating...' : 'Create Subscription'}
</button>
</form>
</div>
</main>
</>
)
}
+85
View File
@@ -0,0 +1,85 @@
'use client'
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { Nav } from '@/components/layout/nav'
import { StatusBadge } from '@/components/common/status-badge'
import { api } from '@/lib/api'
import type { Subscription } from '@/lib/types'
export default function SubscriptionsPage() {
const router = useRouter()
const [subscriptions, setSubscriptions] = useState<Subscription[]>([])
const [error, setError] = useState('')
useEffect(() => {
api.listSubscriptions().then(setSubscriptions).catch((e) => setError(e.message))
}, [])
return (
<>
<Nav />
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<div className="mb-8 flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Subscriptions</h1>
<p className="mt-1 text-sm text-muted-foreground">Manage recurring payments.</p>
</div>
<Link
href="/subscriptions/new"
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
Create Subscription
</Link>
</div>
{error && <p className="mb-4 text-sm text-destructive">{error}</p>}
<div className="overflow-x-auto rounded-lg border border-border">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-muted/50">
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Name</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Recipient</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Amount</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Interval</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Status</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Next Payment</th>
</tr>
</thead>
<tbody>
{subscriptions.map((sub) => (
<tr
key={sub.id}
onClick={() => router.push(`/subscriptions/${sub.id}`)}
className="cursor-pointer border-b border-border last:border-0 hover:bg-muted/30"
>
<td className="px-4 py-3 font-medium">{sub.name}</td>
<td className="px-4 py-3 font-mono text-xs">
{sub.recipient_address.slice(0, 10)}...{sub.recipient_address.slice(-6)}
</td>
<td className="px-4 py-3 font-mono">
{sub.amount} {sub.token || sub.currency}
</td>
<td className="px-4 py-3 capitalize">{sub.interval}</td>
<td className="px-4 py-3"><StatusBadge status={sub.status} /></td>
<td className="px-4 py-3 text-muted-foreground">
{new Date(sub.next_payment_at).toLocaleDateString()}
</td>
</tr>
))}
{subscriptions.length === 0 && (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-muted-foreground">
No subscriptions.
</td>
</tr>
)}
</tbody>
</table>
</div>
</main>
</>
)
}
+217
View File
@@ -0,0 +1,217 @@
'use client'
import { useState, useEffect, type FormEvent } from 'react'
import { Nav } from '@/components/layout/nav'
import { api } from '@/lib/api'
import type { User } from '@/lib/types'
const roleBadgeColors: Record<string, string> = {
owner: 'bg-amber-500/10 text-amber-400 border-amber-500/20',
admin: 'bg-blue-500/10 text-blue-400 border-blue-500/20',
signer: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20',
viewer: 'bg-zinc-500/10 text-zinc-400 border-zinc-500/20',
}
const roleOptions = ['admin', 'signer', 'viewer']
export default function TeamPage() {
const [users, setUsers] = useState<User[]>([])
const [error, setError] = useState('')
// Invite form
const [email, setEmail] = useState('')
const [role, setRole] = useState('signer')
const [password, setPassword] = useState('')
const [inviting, setInviting] = useState(false)
// Inline edit
const [editingId, setEditingId] = useState<string | null>(null)
const [editRole, setEditRole] = useState('')
useEffect(() => {
api.listUsers().then(setUsers).catch((e) => setError(e.message))
}, [])
async function handleInvite(e: FormEvent) {
e.preventDefault()
if (!email.trim() || !password.trim()) return
setInviting(true)
setError('')
try {
const user = await api.inviteUser({ email, role, password })
setUsers((prev) => [...prev, user])
setEmail('')
setPassword('')
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to invite user')
} finally {
setInviting(false)
}
}
async function handleUpdateRole(id: string) {
setError('')
try {
const updated = await api.updateUser(id, { role: editRole })
setUsers((prev) => prev.map((u) => (u.id === id ? updated : u)))
setEditingId(null)
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to update role')
}
}
async function handleDelete(id: string) {
try {
await api.deleteUser(id)
setUsers((prev) => prev.filter((u) => u.id !== id))
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to remove user')
}
}
return (
<>
<Nav />
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<div className="mb-8">
<h1 className="text-2xl font-semibold tracking-tight">Team</h1>
<p className="mt-1 text-sm text-muted-foreground">Manage team members and their roles.</p>
</div>
{error && <p className="mb-4 text-sm text-destructive">{error}</p>}
{/* Invite form */}
<form onSubmit={handleInvite} className="mb-8 rounded-lg border border-border bg-card p-6 space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">Invite User</h2>
<div className="grid gap-4 sm:grid-cols-3">
<div className="space-y-1.5">
<label className="text-sm font-medium text-muted-foreground">Email</label>
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="[email protected]"
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-muted-foreground">Role</label>
<select
value={role}
onChange={(e) => setRole(e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
{roleOptions.map((r) => (
<option key={r} value={r}>{r}</option>
))}
</select>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-muted-foreground">Initial Password</label>
<input
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="Temporary password"
/>
</div>
</div>
<button
type="submit"
disabled={inviting || !email.trim() || !password.trim()}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{inviting ? 'Inviting...' : 'Invite'}
</button>
</form>
{/* User table */}
<div className="overflow-x-auto rounded-lg border border-border">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-muted/50">
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Email</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Role</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Joined</th>
<th className="px-4 py-3 text-right font-medium text-muted-foreground">Actions</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.id} className="border-b border-border last:border-0">
<td className="px-4 py-3 font-medium">{user.email}</td>
<td className="px-4 py-3">
{editingId === user.id ? (
<div className="flex items-center gap-2">
<select
value={editRole}
onChange={(e) => setEditRole(e.target.value)}
className="rounded-md border border-input bg-background px-2 py-1 text-xs focus:outline-none focus:ring-2 focus:ring-ring"
>
{roleOptions.map((r) => (
<option key={r} value={r}>{r}</option>
))}
</select>
<button
onClick={() => handleUpdateRole(user.id)}
className="text-xs text-primary hover:underline"
>
Save
</button>
<button
onClick={() => setEditingId(null)}
className="text-xs text-muted-foreground hover:underline"
>
Cancel
</button>
</div>
) : (
<span
className={`inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium capitalize ${
roleBadgeColors[user.role] ?? 'bg-zinc-500/10 text-zinc-400 border-zinc-500/20'
}`}
>
{user.role}
</span>
)}
</td>
<td className="px-4 py-3 text-muted-foreground">
{new Date(user.created_at).toLocaleDateString()}
</td>
<td className="px-4 py-3 text-right space-x-3">
{editingId !== user.id && user.role !== 'owner' && (
<>
<button
onClick={() => { setEditingId(user.id); setEditRole(user.role) }}
className="text-sm text-muted-foreground hover:text-foreground"
>
Edit
</button>
<button
onClick={() => handleDelete(user.id)}
className="text-sm text-destructive hover:underline"
>
Remove
</button>
</>
)}
</td>
</tr>
))}
{users.length === 0 && (
<tr>
<td colSpan={4} className="px-4 py-8 text-center text-muted-foreground">
No team members yet.
</td>
</tr>
)}
</tbody>
</table>
</div>
</main>
</>
)
}
+231
View File
@@ -0,0 +1,231 @@
'use client'
import { useState, useEffect } from 'react'
import { useParams } from 'next/navigation'
import Link from 'next/link'
import { Nav } from '@/components/layout/nav'
import { StatusBadge } from '@/components/common/status-badge'
import { api } from '@/lib/api'
import type { Transaction } from '@/lib/types'
export default function TransactionDetailPage() {
const { id } = useParams<{ id: string }>()
const [tx, setTx] = useState<Transaction | null>(null)
const [error, setError] = useState('')
const [acting, setActing] = useState(false)
const [rejectReason, setRejectReason] = useState('')
useEffect(() => {
if (!id) return
api.getTransaction(id).then(setTx).catch((e) => setError(e.message))
}, [id])
async function handleApprove() {
setActing(true)
setError('')
try {
await api.approveTransaction(id)
const updated = await api.getTransaction(id)
setTx(updated)
} catch (e) {
setError(e instanceof Error ? e.message : 'Approval failed')
} finally {
setActing(false)
}
}
async function handleReject() {
setActing(true)
setError('')
try {
await api.rejectTransaction(id, { reason: rejectReason || undefined })
const updated = await api.getTransaction(id)
setTx(updated)
} catch (e) {
setError(e instanceof Error ? e.message : 'Rejection failed')
} finally {
setActing(false)
}
}
return (
<>
<Nav />
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<div className="mb-8 flex items-center gap-2 text-sm text-muted-foreground">
<Link href="/transactions" className="hover:text-foreground">Transactions</Link>
<span>/</span>
<span className="font-mono">{id?.slice(0, 8)}...</span>
</div>
{error && <p className="mb-4 text-sm text-destructive">{error}</p>}
{!tx && !error && <p className="text-sm text-muted-foreground">Loading...</p>}
{tx && (
<>
<div className="mb-8 flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight capitalize">{tx.tx_type} Transaction</h1>
<p className="mt-1 font-mono text-xs text-muted-foreground">{tx.id}</p>
</div>
<StatusBadge status={tx.status} />
</div>
{/* Transaction Info */}
<div className="mb-8 rounded-lg border border-border bg-card p-6">
<h2 className="mb-4 text-sm font-medium uppercase tracking-wider text-muted-foreground">Details</h2>
<dl className="grid grid-cols-2 gap-4 text-sm sm:grid-cols-3">
<div>
<dt className="text-muted-foreground">Type</dt>
<dd className="mt-1 capitalize">{tx.tx_type}</dd>
</div>
<div>
<dt className="text-muted-foreground">Chain</dt>
<dd className="mt-1 capitalize">{tx.chain}</dd>
</div>
<div>
<dt className="text-muted-foreground">Amount</dt>
<dd className="mt-1 font-mono">{tx.amount || '--'}</dd>
</div>
{tx.token && (
<div>
<dt className="text-muted-foreground">Token</dt>
<dd className="mt-1 font-mono text-xs">{tx.token}</dd>
</div>
)}
<div className="col-span-2">
<dt className="text-muted-foreground">To Address</dt>
<dd className="mt-1 font-mono text-xs break-all">{tx.to_address || '--'}</dd>
</div>
{tx.wallet_id && (
<div>
<dt className="text-muted-foreground">Wallet</dt>
<dd className="mt-1">
<Link href={`/wallets/${tx.wallet_id}`} className="font-mono text-xs hover:underline">
{tx.wallet_id.slice(0, 12)}...
</Link>
</dd>
</div>
)}
{tx.initiated_by && (
<div>
<dt className="text-muted-foreground">Initiated By</dt>
<dd className="mt-1 font-mono text-xs">{tx.initiated_by}</dd>
</div>
)}
<div>
<dt className="text-muted-foreground">Created</dt>
<dd className="mt-1">{new Date(tx.created_at).toLocaleString()}</dd>
</div>
{tx.signed_at && (
<div>
<dt className="text-muted-foreground">Signed</dt>
<dd className="mt-1">{new Date(tx.signed_at).toLocaleString()}</dd>
</div>
)}
{tx.broadcast_at && (
<div>
<dt className="text-muted-foreground">Broadcast</dt>
<dd className="mt-1">{new Date(tx.broadcast_at).toLocaleString()}</dd>
</div>
)}
</dl>
</div>
{/* TX Hash */}
{tx.tx_hash && (
<div className="mb-8 rounded-lg border border-border bg-card p-6">
<h2 className="mb-4 text-sm font-medium uppercase tracking-wider text-muted-foreground">Transaction Hash</h2>
<p className="font-mono text-xs break-all">{tx.tx_hash}</p>
</div>
)}
{/* Signature */}
{(tx.signature_r || tx.signature_s || tx.signature_eddsa) && (
<div className="mb-8 rounded-lg border border-border bg-card p-6">
<h2 className="mb-4 text-sm font-medium uppercase tracking-wider text-muted-foreground">Signature</h2>
<dl className="space-y-3 text-sm">
{tx.signature_r && (
<div>
<dt className="text-muted-foreground">R</dt>
<dd className="mt-1 font-mono text-xs break-all">{tx.signature_r}</dd>
</div>
)}
{tx.signature_s && (
<div>
<dt className="text-muted-foreground">S</dt>
<dd className="mt-1 font-mono text-xs break-all">{tx.signature_s}</dd>
</div>
)}
{tx.signature_eddsa && (
<div>
<dt className="text-muted-foreground">EdDSA</dt>
<dd className="mt-1 font-mono text-xs break-all">{tx.signature_eddsa}</dd>
</div>
)}
</dl>
</div>
)}
{/* Approvals / Rejection */}
{tx.approved_by && tx.approved_by.length > 0 && (
<div className="mb-8 rounded-lg border border-border bg-card p-6">
<h2 className="mb-4 text-sm font-medium uppercase tracking-wider text-muted-foreground">Approved By</h2>
<div className="flex flex-wrap gap-2">
{tx.approved_by.map((u) => (
<span key={u} className="rounded-md border border-border bg-muted/50 px-2 py-1 font-mono text-xs">{u}</span>
))}
</div>
</div>
)}
{tx.rejected_by && (
<div className="mb-8 rounded-lg border border-destructive/30 bg-destructive/5 p-6">
<h2 className="mb-2 text-sm font-medium text-destructive">Rejected</h2>
<p className="text-sm">By: <span className="font-mono text-xs">{tx.rejected_by}</span></p>
{tx.rejection_reason && (
<p className="mt-1 text-sm text-muted-foreground">Reason: {tx.rejection_reason}</p>
)}
</div>
)}
{/* Actions */}
{tx.status === 'pending_approval' && (
<div className="rounded-lg border border-border bg-card p-6 space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">Actions</h2>
<div className="flex flex-wrap items-end gap-4">
<button
onClick={handleApprove}
disabled={acting}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{acting ? 'Processing...' : 'Approve'}
</button>
<div className="flex items-end gap-2">
<div>
<label className="mb-1.5 block text-xs text-muted-foreground">Reason (optional)</label>
<input
type="text"
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
className="rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="Rejection reason"
/>
</div>
<button
onClick={handleReject}
disabled={acting}
className="rounded-md border border-destructive/30 px-4 py-2 text-sm font-medium text-destructive hover:bg-destructive/10 disabled:opacity-50"
>
Reject
</button>
</div>
</div>
</div>
)}
</>
)}
</main>
</>
)
}
+103
View File
@@ -0,0 +1,103 @@
'use client'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { Nav } from '@/components/layout/nav'
import { StatusBadge } from '@/components/common/status-badge'
import { api } from '@/lib/api'
import type { Transaction } from '@/lib/types'
const statusOptions = ['all', 'pending', 'pending_approval', 'approved', 'signing', 'signed', 'broadcast', 'failed', 'rejected']
const chainOptions = ['all', 'ethereum', 'bitcoin', 'lux', 'solana', 'xrpl']
export default function TransactionsPage() {
const router = useRouter()
const [transactions, setTransactions] = useState<Transaction[]>([])
const [statusFilter, setStatusFilter] = useState('all')
const [chainFilter, setChainFilter] = useState('all')
const [error, setError] = useState('')
useEffect(() => {
const filters: { status?: string; chain?: string } = {}
if (statusFilter !== 'all') filters.status = statusFilter
if (chainFilter !== 'all') filters.chain = chainFilter
api.listTransactions(filters).then(setTransactions).catch((e) => setError(e.message))
}, [statusFilter, chainFilter])
return (
<>
<Nav />
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<div className="mb-8">
<h1 className="text-2xl font-semibold tracking-tight">Transactions</h1>
<p className="mt-1 text-sm text-muted-foreground">View and manage all transactions.</p>
</div>
{error && <p className="mb-4 text-sm text-destructive">{error}</p>}
{/* Filters */}
<div className="mb-6 flex flex-wrap items-center gap-3">
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
{statusOptions.map((s) => (
<option key={s} value={s}>{s === 'all' ? 'All Status' : s.replace(/_/g, ' ')}</option>
))}
</select>
<select
value={chainFilter}
onChange={(e) => setChainFilter(e.target.value)}
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
{chainOptions.map((c) => (
<option key={c} value={c}>{c === 'all' ? 'All Chains' : c}</option>
))}
</select>
</div>
{/* Table */}
<div className="overflow-x-auto rounded-lg border border-border">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-muted/50">
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Type</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Chain</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">To</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Amount</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Status</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Date</th>
</tr>
</thead>
<tbody>
{transactions.map((tx) => (
<tr
key={tx.id}
onClick={() => router.push(`/transactions/${tx.id}`)}
className="cursor-pointer border-b border-border last:border-0 hover:bg-muted/30"
>
<td className="px-4 py-3 capitalize">{tx.tx_type}</td>
<td className="px-4 py-3 capitalize">{tx.chain}</td>
<td className="px-4 py-3 font-mono text-xs">
{tx.to_address ? `${tx.to_address.slice(0, 10)}...${tx.to_address.slice(-6)}` : '--'}
</td>
<td className="px-4 py-3 font-mono">{tx.amount || '--'}</td>
<td className="px-4 py-3"><StatusBadge status={tx.status} /></td>
<td className="px-4 py-3 text-muted-foreground">{new Date(tx.created_at).toLocaleDateString()}</td>
</tr>
))}
{transactions.length === 0 && (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-muted-foreground">
No transactions found.
</td>
</tr>
)}
</tbody>
</table>
</div>
</main>
</>
)
}
+98
View File
@@ -0,0 +1,98 @@
'use client'
import { useState, useEffect } from 'react'
import { useParams } from 'next/navigation'
import Link from 'next/link'
import { Nav } from '@/components/layout/nav'
import { DataTable, type Column } from '@/components/common/data-table'
import { StatusBadge } from '@/components/common/status-badge'
import { api } from '@/lib/api'
import type { Vault, Wallet } from '@/lib/types'
const columns: Column<Wallet>[] = [
{ key: 'name', header: 'Name', render: (row) => <span>{row.name || row.wallet_id.slice(0, 12)}</span> },
{ key: 'key_type', header: 'Type', render: (row) => <span className="font-mono text-xs">{row.key_type}</span> },
{
key: 'eth_address',
header: 'Address',
render: (row) => (
<span className="font-mono text-xs">
{row.eth_address ? `${row.eth_address.slice(0, 10)}...${row.eth_address.slice(-8)}` : '--'}
</span>
),
},
{
key: 'status',
header: 'Status',
render: (row) => <StatusBadge status={row.status} />,
},
{
key: 'created_at',
header: 'Created',
render: (row) => <span className="text-muted-foreground">{new Date(row.created_at).toLocaleDateString()}</span>,
},
]
export default function VaultDetailPage() {
const params = useParams<{ id: string }>()
const [vault, setVault] = useState<Vault | null>(null)
const [wallets, setWallets] = useState<Wallet[]>([])
useEffect(() => {
if (!params.id) return
api.getVault(params.id).then(setVault).catch(console.error)
api.listWallets(params.id).then(setWallets).catch(console.error)
}, [params.id])
return (
<>
<Nav />
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
{/* Vault header */}
<div className="mb-8">
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-2">
<Link href="/vaults" className="hover:text-foreground">Vaults</Link>
<span>/</span>
<span>{vault?.name ?? 'Loading...'}</span>
</div>
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">{vault?.name ?? 'Loading...'}</h1>
{vault?.description && (
<p className="mt-1 text-sm text-muted-foreground">{vault.description}</p>
)}
{vault?.app_id && (
<p className="mt-1 text-xs text-muted-foreground font-mono">
App ID: {vault.app_id}
</p>
)}
</div>
<div className="flex items-center gap-2">
<Link
href={`/policies?vault=${params.id}`}
className="rounded-md border border-border px-4 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent"
>
Policies
</Link>
<Link
href={`/vaults/${params.id}/wallets/new`}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
Create Wallet
</Link>
</div>
</div>
</div>
{/* Wallets table */}
<h2 className="mb-4 text-lg font-semibold">Wallets</h2>
<DataTable<Wallet>
columns={columns}
data={wallets}
keyField="id"
emptyMessage="No wallets in this vault yet."
/>
</main>
</>
)
}
@@ -0,0 +1,231 @@
'use client'
import { useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import Link from 'next/link'
import { Nav } from '@/components/layout/nav'
type KeyType = 'secp256k1' | 'ed25519'
type Step = 'type' | 'name' | 'generate' | 'result'
interface KeygenResult {
wallet_id: string
eth_address: string
btc_address: string
sol_address: string
public_key: string
}
export default function NewWalletPage() {
const params = useParams<{ id: string }>()
const router = useRouter()
const [step, setStep] = useState<Step>('type')
const [keyType, setKeyType] = useState<KeyType>('secp256k1')
const [walletName, setWalletName] = useState('')
const [generating, setGenerating] = useState(false)
const [error, setError] = useState('')
const [result, setResult] = useState<KeygenResult | null>(null)
async function handleGenerate() {
if (!walletName.trim()) return
setGenerating(true)
setError('')
setStep('generate')
try {
const res = await fetch(`/api/vaults/${params.id}/wallets`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('access_token')}`,
},
body: JSON.stringify({ name: walletName, key_type: keyType }),
})
const data = await res.json()
if (!res.ok) {
throw new Error(data.error || 'Key generation failed')
}
setResult(data)
setStep('result')
} catch (err) {
setError(err instanceof Error ? err.message : 'Key generation failed')
setStep('name')
} finally {
setGenerating(false)
}
}
return (
<>
<Nav />
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<div className="mb-8 flex items-center gap-2 text-sm text-muted-foreground">
<Link href="/vaults" className="hover:text-foreground">Vaults</Link>
<span>/</span>
<Link href={`/vaults/${params.id}`} className="hover:text-foreground">Vault</Link>
<span>/</span>
<span>New Wallet</span>
</div>
<h1 className="mb-8 text-2xl font-semibold tracking-tight">Generate New Wallet</h1>
<div className="mx-auto max-w-lg">
{/* Step indicator */}
<div className="mb-8 flex items-center justify-between">
{(['type', 'name', 'generate', 'result'] as Step[]).map((s, i) => (
<div key={s} className="flex items-center gap-2">
<div
className={`flex h-8 w-8 items-center justify-center rounded-full text-xs font-semibold ${
step === s
? 'bg-primary text-primary-foreground'
: i < ['type', 'name', 'generate', 'result'].indexOf(step)
? 'bg-emerald-500/20 text-emerald-400'
: 'bg-muted text-muted-foreground'
}`}
>
{i + 1}
</div>
{i < 3 && <div className="h-px w-12 bg-border sm:w-16" />}
</div>
))}
</div>
{/* Step: Choose key type */}
{step === 'type' && (
<div className="space-y-4">
<h2 className="text-lg font-semibold">Choose Key Type</h2>
<div className="grid gap-3 sm:grid-cols-2">
{(['secp256k1', 'ed25519'] as KeyType[]).map((t) => (
<button
key={t}
onClick={() => setKeyType(t)}
className={`rounded-lg border p-4 text-left transition-colors ${
keyType === t
? 'border-primary bg-primary/5'
: 'border-border hover:border-foreground/20'
}`}
>
<p className="font-semibold font-mono text-sm">{t}</p>
<p className="mt-1 text-xs text-muted-foreground">
{t === 'secp256k1'
? 'Bitcoin, Ethereum, Lux, XRPL'
: 'Solana, TON, Polkadot'}
</p>
</button>
))}
</div>
<div className="flex justify-end">
<button
onClick={() => setStep('name')}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
Next
</button>
</div>
</div>
)}
{/* Step: Name wallet */}
{step === 'name' && (
<div className="space-y-4">
<h2 className="text-lg font-semibold">Name Your Wallet</h2>
<div>
<label className="mb-1.5 block text-sm font-medium text-muted-foreground">
Wallet Name
</label>
<input
type="text"
value={walletName}
onChange={(e) => setWalletName(e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="Hot Wallet"
/>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<div className="flex justify-between">
<button
onClick={() => setStep('type')}
className="rounded-md border border-border px-4 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent"
>
Back
</button>
<button
onClick={handleGenerate}
disabled={!walletName.trim()}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
>
Generate
</button>
</div>
</div>
)}
{/* Step: Generating */}
{step === 'generate' && (
<div className="flex flex-col items-center py-12">
<div className="mb-4 h-8 w-8 animate-spin rounded-full border-2 border-muted border-t-primary" />
<p className="text-sm text-muted-foreground">
Running distributed key generation...
</p>
<p className="mt-1 text-xs text-muted-foreground">
This may take up to 30 seconds.
</p>
</div>
)}
{/* Step: Result */}
{step === 'result' && result && (
<div className="space-y-4">
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-emerald-500/20 text-emerald-400">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
<h2 className="text-lg font-semibold">Wallet Created</h2>
</div>
<div className="space-y-3 rounded-lg border border-border bg-muted/50 p-4">
{result.eth_address && (
<div>
<p className="text-xs text-muted-foreground">ETH Address</p>
<p className="font-mono text-sm break-all">{result.eth_address}</p>
</div>
)}
{result.btc_address && (
<div>
<p className="text-xs text-muted-foreground">BTC Address</p>
<p className="font-mono text-sm break-all">{result.btc_address}</p>
</div>
)}
{result.sol_address && (
<div>
<p className="text-xs text-muted-foreground">SOL Address</p>
<p className="font-mono text-sm break-all">{result.sol_address}</p>
</div>
)}
<div>
<p className="text-xs text-muted-foreground">Public Key</p>
<p className="font-mono text-xs break-all">{result.public_key}</p>
</div>
</div>
<div className="flex justify-end">
<button
onClick={() => router.push(`/vaults/${params.id}`)}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
Done
</button>
</div>
</div>
)}
</div>
</main>
</>
)
}
+136
View File
@@ -0,0 +1,136 @@
'use client'
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { Nav } from '@/components/layout/nav'
import { api } from '@/lib/api'
import type { Vault } from '@/lib/types'
export default function VaultsPage() {
const [vaults, setVaults] = useState<Vault[]>([])
const [showCreate, setShowCreate] = useState(false)
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [creating, setCreating] = useState(false)
useEffect(() => {
api.listVaults().then(setVaults).catch(console.error)
}, [])
async function handleCreate() {
if (!name.trim()) return
setCreating(true)
try {
await api.createVault({ name, description })
setShowCreate(false)
setName('')
setDescription('')
api.listVaults().then(setVaults).catch(console.error)
} catch {
// error handled by API client
} finally {
setCreating(false)
}
}
return (
<>
<Nav />
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<div className="mb-8 flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Vaults</h1>
<p className="mt-1 text-sm text-muted-foreground">
Manage your MPC key vaults.
</p>
</div>
<button
onClick={() => setShowCreate(true)}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
Create Vault
</button>
</div>
{/* Create vault dialog */}
{showCreate && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm">
<div className="w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-lg">
<h2 className="mb-4 text-lg font-semibold">Create Vault</h2>
<div className="space-y-4">
<div>
<label className="mb-1.5 block text-sm font-medium text-muted-foreground">
Name
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="Treasury Vault"
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-muted-foreground">
Description
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="Optional description"
/>
</div>
<div className="flex justify-end gap-2">
<button
onClick={() => setShowCreate(false)}
className="rounded-md border border-border px-4 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent"
>
Cancel
</button>
<button
onClick={handleCreate}
disabled={creating || !name.trim()}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
>
{creating ? 'Creating...' : 'Create'}
</button>
</div>
</div>
</div>
</div>
)}
{/* Vault cards grid */}
{vaults.length === 0 ? (
<div className="rounded-lg border border-border bg-card p-12 text-center">
<p className="text-muted-foreground">No vaults yet. Create one to get started.</p>
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{vaults.map((vault) => (
<Link
key={vault.id}
href={`/vaults/${vault.id}`}
className="group rounded-lg border border-border bg-card p-6 transition-colors hover:border-foreground/20"
>
<h3 className="font-semibold group-hover:text-foreground">
{vault.name}
</h3>
{vault.description && (
<p className="mt-1 text-sm text-muted-foreground line-clamp-2">
{vault.description}
</p>
)}
<div className="mt-4 flex items-center justify-between text-xs text-muted-foreground">
<span>{new Date(vault.created_at).toLocaleDateString()}</span>
</div>
</Link>
))}
</div>
)}
</main>
</>
)
}
+190
View File
@@ -0,0 +1,190 @@
'use client'
import { useState, useEffect } from 'react'
import { useParams } from 'next/navigation'
import Link from 'next/link'
import { Nav } from '@/components/layout/nav'
import { StatusBadge } from '@/components/common/status-badge'
import { api } from '@/lib/api'
import type { Wallet, WalletAddresses, Transaction } from '@/lib/types'
export default function WalletDetailPage() {
const { id } = useParams<{ id: string }>()
const [wallet, setWallet] = useState<Wallet | null>(null)
const [addresses, setAddresses] = useState<WalletAddresses | null>(null)
const [transactions, setTransactions] = useState<Transaction[]>([])
const [error, setError] = useState('')
useEffect(() => {
if (!id) return
api.getWallet(id).then(setWallet).catch((e) => setError(e.message))
api.getWalletAddresses(id).then(setAddresses).catch(() => {})
api.getWalletHistory(id).then(setTransactions).catch(() => {})
}, [id])
return (
<>
<Nav />
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<div className="mb-8 flex items-center gap-2 text-sm text-muted-foreground">
<Link href="/vaults" className="hover:text-foreground">Vaults</Link>
<span>/</span>
<span>Wallet</span>
</div>
{error && <p className="mb-4 text-sm text-destructive">{error}</p>}
{wallet && (
<>
{/* Header */}
<div className="mb-8 flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">
{wallet.name || 'Unnamed Wallet'}
</h1>
<p className="mt-1 text-sm text-muted-foreground">
<span className="font-mono">{wallet.key_type}</span>
{' -- '}
Threshold: {wallet.threshold} of {wallet.participants.length}
</p>
<div className="mt-2">
<StatusBadge status={wallet.status} />
</div>
</div>
<div className="flex items-center gap-2">
<Link
href={`/wallets/${id}/reshare`}
className="rounded-md border border-border px-4 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent"
>
Reshare
</Link>
<Link
href={`/wallets/${id}/smart-wallets`}
className="rounded-md border border-border px-4 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent"
>
Smart Wallets
</Link>
<Link
href={`/wallets/${id}/send`}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
Send
</Link>
</div>
</div>
{/* Wallet Info */}
<div className="mb-8 rounded-lg border border-border bg-card p-6">
<h2 className="mb-4 text-sm font-medium text-muted-foreground">Wallet Info</h2>
<dl className="grid grid-cols-2 gap-4 text-sm sm:grid-cols-3">
<div>
<dt className="text-muted-foreground">Wallet ID</dt>
<dd className="mt-1 font-mono text-xs">{wallet.wallet_id}</dd>
</div>
<div>
<dt className="text-muted-foreground">Key Type</dt>
<dd className="mt-1 font-mono">{wallet.key_type}</dd>
</div>
<div>
<dt className="text-muted-foreground">Threshold</dt>
<dd className="mt-1">{wallet.threshold} of {wallet.participants.length}</dd>
</div>
<div>
<dt className="text-muted-foreground">Version</dt>
<dd className="mt-1">{wallet.version}</dd>
</div>
<div>
<dt className="text-muted-foreground">Created</dt>
<dd className="mt-1">{new Date(wallet.created_at).toLocaleDateString()}</dd>
</div>
</dl>
{/* Participants */}
<div className="mt-6">
<h3 className="mb-2 text-sm font-medium text-muted-foreground">Participants</h3>
<div className="flex flex-wrap gap-2">
{wallet.participants.map((p) => (
<span key={p} className="rounded-md border border-border bg-muted/50 px-2 py-1 font-mono text-xs">
{p}
</span>
))}
</div>
</div>
</div>
{/* Addresses */}
<div className="mb-8 rounded-lg border border-border bg-card p-6">
<h2 className="mb-4 text-sm font-medium text-muted-foreground">Addresses</h2>
<div className="space-y-3">
{(wallet.eth_address || addresses?.ethereum) && (
<div className="rounded-md bg-muted/50 px-4 py-3">
<p className="text-xs text-muted-foreground">Ethereum / EVM</p>
<p className="font-mono text-sm">{wallet.eth_address || addresses?.ethereum}</p>
</div>
)}
{(wallet.btc_address || addresses?.bitcoin) && (
<div className="rounded-md bg-muted/50 px-4 py-3">
<p className="text-xs text-muted-foreground">Bitcoin</p>
<p className="font-mono text-sm">{wallet.btc_address || addresses?.bitcoin}</p>
</div>
)}
{(wallet.sol_address || addresses?.solana) && (
<div className="rounded-md bg-muted/50 px-4 py-3">
<p className="text-xs text-muted-foreground">Solana</p>
<p className="font-mono text-sm">{wallet.sol_address || addresses?.solana}</p>
</div>
)}
{!wallet.eth_address && !wallet.btc_address && !wallet.sol_address &&
!addresses?.ethereum && !addresses?.bitcoin && !addresses?.solana && (
<p className="text-sm text-muted-foreground">No addresses generated yet.</p>
)}
</div>
</div>
{/* Recent Transactions */}
<h2 className="mb-4 text-lg font-semibold">Recent Transactions</h2>
<div className="overflow-x-auto rounded-lg border border-border">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-muted/50">
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Type</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Chain</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">To</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Amount</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Status</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Date</th>
</tr>
</thead>
<tbody>
{transactions.map((tx) => (
<tr key={tx.id} className="border-b border-border last:border-0 hover:bg-muted/30">
<td className="px-4 py-3 capitalize">{tx.tx_type}</td>
<td className="px-4 py-3 capitalize">{tx.chain}</td>
<td className="px-4 py-3 font-mono text-xs">
{tx.to_address ? `${tx.to_address.slice(0, 10)}...${tx.to_address.slice(-6)}` : '--'}
</td>
<td className="px-4 py-3 font-mono">{tx.amount || '--'}</td>
<td className="px-4 py-3"><StatusBadge status={tx.status} /></td>
<td className="px-4 py-3 text-muted-foreground">{new Date(tx.created_at).toLocaleDateString()}</td>
</tr>
))}
{transactions.length === 0 && (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-muted-foreground">
No transactions yet.
</td>
</tr>
)}
</tbody>
</table>
</div>
</>
)}
{!wallet && !error && (
<p className="text-sm text-muted-foreground">Loading...</p>
)}
</main>
</>
)
}
+234
View File
@@ -0,0 +1,234 @@
'use client'
import { useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { ReshareWizard } from '@/components/wallets/reshare-wizard'
interface WalletInfo {
id: string
name: string
threshold: number
participants: string[]
ecdsaPubkey: string
}
type ReshareStatus = 'idle' | 'confirming' | 'running' | 'success' | 'error'
export default function ResharePage() {
const params = useParams<{ id: string }>()
const router = useRouter()
// TODO: fetch wallet info from API
const [wallet] = useState<WalletInfo>({
id: params.id,
name: 'Vault Wallet 1',
threshold: 2,
participants: ['node0', 'node1', 'node2'],
ecdsaPubkey: '0x04abc...def',
})
const [newThreshold, setNewThreshold] = useState(wallet.threshold)
const [newParticipants, setNewParticipants] = useState(
wallet.participants.join('\n')
)
const [status, setStatus] = useState<ReshareStatus>('idle')
const [error, setError] = useState('')
const participantList = newParticipants
.split('\n')
.map((p) => p.trim())
.filter(Boolean)
async function handleConfirm() {
setStatus('confirming')
}
async function handleReshare() {
setStatus('running')
setError('')
try {
// TODO: call MPC reshare API
await new Promise((resolve) => setTimeout(resolve, 3000))
setStatus('success')
} catch (err) {
setError(err instanceof Error ? err.message : 'Reshare failed')
setStatus('error')
}
}
return (
<div className="mx-auto max-w-2xl space-y-8 px-4 py-8">
<div>
<h1 className="text-2xl font-semibold tracking-tight">
Reshare Key Shares
</h1>
<p className="mt-1 text-sm text-muted-foreground">
Rotate key shares for wallet{' '}
<span className="font-mono">{wallet.name}</span> without changing the
public key or on-chain addresses.
</p>
</div>
{/* Current wallet info */}
<section className="rounded-lg border border-border bg-card p-6 space-y-4">
<h2 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
Current Configuration
</h2>
<dl className="grid grid-cols-2 gap-4 text-sm">
<div>
<dt className="text-muted-foreground">Threshold</dt>
<dd className="mt-1 font-mono">
{wallet.threshold}-of-{wallet.participants.length}
</dd>
</div>
<div>
<dt className="text-muted-foreground">Participants</dt>
<dd className="mt-1 font-mono">
{wallet.participants.length} nodes
</dd>
</div>
<div className="col-span-2">
<dt className="text-muted-foreground">ECDSA Public Key</dt>
<dd className="mt-1 truncate font-mono text-xs">
{wallet.ecdsaPubkey}
</dd>
</div>
</dl>
</section>
{/* Warning */}
<div className="rounded-lg border border-yellow-600/30 bg-yellow-600/10 p-4 text-sm">
<p className="font-medium text-yellow-500">Key Rotation Warning</p>
<p className="mt-1 text-yellow-500/80">
Resharing invalidates all existing key shares. All participants must be
online and reachable during the process. Ensure backups exist before
proceeding.
</p>
</div>
{status === 'idle' && (
<section className="space-y-6">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="threshold">
New Threshold
</label>
<input
id="threshold"
type="number"
min={1}
max={participantList.length}
value={newThreshold}
onChange={(e) => setNewThreshold(Number(e.target.value))}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
<p className="text-xs text-muted-foreground">
Minimum signers required (1 to {participantList.length})
</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="participants">
New Participants (one per line)
</label>
<textarea
id="participants"
rows={5}
value={newParticipants}
onChange={(e) => setNewParticipants(e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
<p className="text-xs text-muted-foreground">
{participantList.length} participant
{participantList.length !== 1 ? 's' : ''} configured
</p>
</div>
<button
onClick={handleConfirm}
disabled={participantList.length < 1 || newThreshold < 1}
className="w-full rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
Review Reshare
</button>
</section>
)}
{status === 'confirming' && (
<section className="space-y-6">
<div className="rounded-lg border border-border bg-card p-6 space-y-3 text-sm">
<h2 className="font-medium">Confirm Reshare</h2>
<p>
<span className="text-muted-foreground">New threshold:</span>{' '}
<span className="font-mono">
{newThreshold}-of-{participantList.length}
</span>
</p>
<p className="text-muted-foreground">New participants:</p>
<ul className="list-inside list-disc font-mono text-xs space-y-1">
{participantList.map((p) => (
<li key={p}>{p}</li>
))}
</ul>
</div>
<div className="flex gap-3">
<button
onClick={() => setStatus('idle')}
className="flex-1 rounded-md border border-border px-4 py-2 text-sm font-medium hover:bg-accent"
>
Back
</button>
<button
onClick={handleReshare}
className="flex-1 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
Confirm Reshare
</button>
</div>
</section>
)}
{status === 'running' && (
<ReshareWizard
step="resharing"
threshold={newThreshold}
participants={participantList}
/>
)}
{status === 'success' && (
<section className="rounded-lg border border-green-600/30 bg-green-600/10 p-6 text-center space-y-4">
<p className="text-lg font-medium text-green-500">
Reshare Complete
</p>
<p className="text-sm text-green-500/80">
Key shares have been rotated. New threshold:{' '}
<span className="font-mono">
{newThreshold}-of-{participantList.length}
</span>
</p>
<button
onClick={() => router.push(`/wallets/${params.id}`)}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
Back to Wallet
</button>
</section>
)}
{status === 'error' && (
<section className="rounded-lg border border-destructive/30 bg-destructive/10 p-6 text-center space-y-4">
<p className="text-lg font-medium text-destructive">
Reshare Failed
</p>
<p className="text-sm text-destructive/80">{error}</p>
<button
onClick={() => setStatus('idle')}
className="rounded-md border border-border px-4 py-2 text-sm font-medium hover:bg-accent"
>
Try Again
</button>
</section>
)}
</div>
)
}
+198
View File
@@ -0,0 +1,198 @@
'use client'
import { useState, type FormEvent } from 'react'
import { useParams, useRouter } from 'next/navigation'
import Link from 'next/link'
import { Nav } from '@/components/layout/nav'
import { api } from '@/lib/api'
const chains = [
{ value: 'ethereum', label: 'Ethereum' },
{ value: 'bitcoin', label: 'Bitcoin' },
{ value: 'lux', label: 'Lux' },
{ value: 'solana', label: 'Solana' },
{ value: 'xrpl', label: 'XRPL' },
]
const txTypes = [
{ value: 'transfer', label: 'Transfer' },
{ value: 'contract_call', label: 'Contract Call' },
]
export default function SendPage() {
const { id } = useParams<{ id: string }>()
const router = useRouter()
const [chain, setChain] = useState('ethereum')
const [toAddress, setToAddress] = useState('')
const [amount, setAmount] = useState('')
const [token, setToken] = useState('')
const [txType, setTxType] = useState('transfer')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const [showPreview, setShowPreview] = useState(false)
async function handleSubmit(e: FormEvent) {
e.preventDefault()
if (!showPreview) {
setShowPreview(true)
return
}
setLoading(true)
setError('')
try {
const tx = await api.createTransaction({
wallet_id: id,
tx_type: txType,
chain,
to_address: toAddress,
amount,
token: token || undefined,
})
router.push(`/transactions/${tx.id}`)
} catch (err) {
setError(err instanceof Error ? err.message : 'Transaction failed')
} finally {
setLoading(false)
}
}
return (
<>
<Nav />
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<div className="mb-8 flex items-center gap-2 text-sm text-muted-foreground">
<Link href={`/wallets/${id}`} className="hover:text-foreground">Wallet</Link>
<span>/</span>
<span>Send</span>
</div>
<h1 className="mb-8 text-2xl font-semibold tracking-tight">Send Transaction</h1>
<div className="mx-auto max-w-lg">
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="mb-1.5 block text-sm font-medium text-muted-foreground">Chain</label>
<select
value={chain}
onChange={(e) => setChain(e.target.value)}
disabled={showPreview}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
{chains.map((c) => (
<option key={c.value} value={c.value}>{c.label}</option>
))}
</select>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-muted-foreground">Transaction Type</label>
<select
value={txType}
onChange={(e) => setTxType(e.target.value)}
disabled={showPreview}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
{txTypes.map((t) => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-muted-foreground">To Address</label>
<input
type="text"
required
value={toAddress}
onChange={(e) => setToAddress(e.target.value)}
disabled={showPreview}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="0x..."
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-muted-foreground">Amount</label>
<input
type="text"
required
value={amount}
onChange={(e) => setAmount(e.target.value)}
disabled={showPreview}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="0.0"
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-muted-foreground">Token (optional)</label>
<input
type="text"
value={token}
onChange={(e) => setToken(e.target.value)}
disabled={showPreview}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="Contract address or leave blank for native"
/>
</div>
{showPreview && (
<div className="rounded-lg border border-border bg-muted/50 p-4 space-y-2">
<h3 className="text-sm font-medium">Preview</h3>
<div className="grid grid-cols-2 gap-2 text-sm">
<span className="text-muted-foreground">Chain</span>
<span className="capitalize">{chain}</span>
<span className="text-muted-foreground">Type</span>
<span className="capitalize">{txType.replace('_', ' ')}</span>
<span className="text-muted-foreground">To</span>
<span className="font-mono text-xs break-all">{toAddress}</span>
<span className="text-muted-foreground">Amount</span>
<span className="font-mono">{amount}</span>
{token && (
<>
<span className="text-muted-foreground">Token</span>
<span className="font-mono text-xs break-all">{token}</span>
</>
)}
</div>
</div>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
<div className="flex justify-between pt-2">
{showPreview ? (
<>
<button
type="button"
onClick={() => setShowPreview(false)}
className="rounded-md border border-border px-4 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent"
>
Edit
</button>
<button
type="submit"
disabled={loading}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
>
{loading ? 'Submitting...' : 'Confirm & Send'}
</button>
</>
) : (
<button
type="submit"
className="ml-auto rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
Preview
</button>
)}
</div>
</form>
</div>
</main>
</>
)
}
@@ -0,0 +1,377 @@
'use client'
import { useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { DeployWizard } from '@/components/smart-wallets/deploy-wizard'
type WalletType = 'safe' | 'erc4337'
type DeployStep = 'type' | 'config' | 'advanced' | 'deploy'
interface DeployConfig {
type: WalletType
chain: string
chainId: number
owners: string[]
threshold: number
factoryAddress: string
salt: string
}
const chains = [
{ name: 'Ethereum', chainId: 1 },
{ name: 'Lux C-Chain', chainId: 96369 },
{ name: 'Lux Testnet', chainId: 96368 },
{ name: 'Zoo', chainId: 200200 },
{ name: 'Hanzo', chainId: 36963 },
]
export default function DeploySmartWalletPage() {
const params = useParams<{ id: string }>()
const router = useRouter()
const [step, setStep] = useState<DeployStep>('type')
const [config, setConfig] = useState<DeployConfig>({
type: 'safe',
chain: 'Ethereum',
chainId: 1,
owners: [''],
threshold: 1,
factoryAddress: '',
salt: '',
})
const [deploying, setDeploying] = useState(false)
const [deployedAddress, setDeployedAddress] = useState('')
const [error, setError] = useState('')
function addOwner() {
setConfig((c) => ({ ...c, owners: [...c.owners, ''] }))
}
function updateOwner(index: number, value: string) {
setConfig((c) => {
const owners = [...c.owners]
owners[index] = value
return { ...c, owners }
})
}
function removeOwner(index: number) {
setConfig((c) => ({
...c,
owners: c.owners.filter((_, i) => i !== index),
}))
}
async function handleDeploy() {
setDeploying(true)
setError('')
try {
// TODO: call deploy API
await new Promise((resolve) => setTimeout(resolve, 5000))
setDeployedAddress('0x9876...fedc')
} catch (err) {
setError(err instanceof Error ? err.message : 'Deployment failed')
} finally {
setDeploying(false)
}
}
return (
<div className="mx-auto max-w-2xl space-y-8 px-4 py-8">
<div>
<h1 className="text-2xl font-semibold tracking-tight">
Deploy Smart Wallet
</h1>
<p className="mt-1 text-sm text-muted-foreground">
Deploy a Safe or ERC-4337 wallet backed by MPC key{' '}
<span className="font-mono">{params.id}</span>
</p>
</div>
{/* Step indicator */}
<DeployWizard currentStep={step} />
{/* Step 1: Choose type */}
{step === 'type' && (
<section className="space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
Step 1: Choose Wallet Type
</h2>
<div className="grid grid-cols-2 gap-4">
{(['safe', 'erc4337'] as const).map((t) => (
<button
key={t}
onClick={() => setConfig((c) => ({ ...c, type: t }))}
className={`rounded-lg border p-6 text-left transition-colors ${
config.type === t
? 'border-primary bg-primary/5'
: 'border-border hover:border-muted-foreground'
}`}
>
<p className="text-sm font-medium">
{t === 'safe' ? 'Safe (Gnosis Safe)' : 'ERC-4337 Account'}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{t === 'safe'
? 'Multi-sig smart wallet with on-chain approvals'
: 'Account abstraction with bundler and paymaster support'}
</p>
</button>
))}
</div>
<button
onClick={() => setStep('config')}
className="w-full rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
Next
</button>
</section>
)}
{/* Step 2: Chain, owners, threshold */}
{step === 'config' && (
<section className="space-y-6">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
Step 2: Configuration
</h2>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="chain">
Chain
</label>
<select
id="chain"
value={config.chainId}
onChange={(e) => {
const chain = chains.find(
(c) => c.chainId === Number(e.target.value)
)
if (chain) {
setConfig((c) => ({
...c,
chain: chain.name,
chainId: chain.chainId,
}))
}
}}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
{chains.map((c) => (
<option key={c.chainId} value={c.chainId}>
{c.name} ({c.chainId})
</option>
))}
</select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Owners</label>
{config.owners.map((owner, i) => (
<div key={i} className="flex gap-2">
<input
type="text"
value={owner}
onChange={(e) => updateOwner(i, e.target.value)}
placeholder="0x..."
className="flex-1 rounded-md border border-input bg-background px-3 py-2 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
{config.owners.length > 1 && (
<button
onClick={() => removeOwner(i)}
className="rounded-md border border-border px-3 py-2 text-sm text-muted-foreground hover:bg-accent"
>
Remove
</button>
)}
</div>
))}
<button
onClick={addOwner}
className="text-sm font-medium text-primary hover:underline"
>
+ Add Owner
</button>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="sw-threshold">
Threshold
</label>
<input
id="sw-threshold"
type="number"
min={1}
max={config.owners.length}
value={config.threshold}
onChange={(e) =>
setConfig((c) => ({ ...c, threshold: Number(e.target.value) }))
}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<div className="flex gap-3">
<button
onClick={() => setStep('type')}
className="flex-1 rounded-md border border-border px-4 py-2 text-sm font-medium hover:bg-accent"
>
Back
</button>
<button
onClick={() => setStep('advanced')}
className="flex-1 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
Next
</button>
</div>
</section>
)}
{/* Step 3: Advanced options */}
{step === 'advanced' && (
<section className="space-y-6">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
Step 3: Advanced Options (Optional)
</h2>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="factory">
Factory Address
</label>
<input
id="factory"
type="text"
value={config.factoryAddress}
onChange={(e) =>
setConfig((c) => ({ ...c, factoryAddress: e.target.value }))
}
placeholder="Leave empty for default"
className="w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="salt">
Salt (CREATE2)
</label>
<input
id="salt"
type="text"
value={config.salt}
onChange={(e) =>
setConfig((c) => ({ ...c, salt: e.target.value }))
}
placeholder="Leave empty for random"
className="w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<div className="flex gap-3">
<button
onClick={() => setStep('config')}
className="flex-1 rounded-md border border-border px-4 py-2 text-sm font-medium hover:bg-accent"
>
Back
</button>
<button
onClick={() => setStep('deploy')}
className="flex-1 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
Review & Deploy
</button>
</div>
</section>
)}
{/* Step 4: Deploy */}
{step === 'deploy' && !deployedAddress && (
<section className="space-y-6">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
Step 4: Deploy
</h2>
<div className="rounded-lg border border-border bg-card p-6 space-y-3 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Type</span>
<span className="font-mono">
{config.type === 'safe' ? 'Gnosis Safe' : 'ERC-4337'}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Chain</span>
<span>
{config.chain} ({config.chainId})
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Threshold</span>
<span className="font-mono">
{config.threshold}-of-{config.owners.filter(Boolean).length}
</span>
</div>
<div>
<span className="text-muted-foreground">Owners</span>
<ul className="mt-1 space-y-1">
{config.owners.filter(Boolean).map((o, i) => (
<li key={i} className="truncate font-mono text-xs">
{o}
</li>
))}
</ul>
</div>
</div>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
<div className="flex gap-3">
<button
onClick={() => setStep('advanced')}
disabled={deploying}
className="flex-1 rounded-md border border-border px-4 py-2 text-sm font-medium hover:bg-accent disabled:opacity-50"
>
Back
</button>
<button
onClick={handleDeploy}
disabled={deploying}
className="flex-1 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{deploying ? 'Deploying...' : 'Deploy'}
</button>
</div>
{deploying && (
<div className="flex items-center justify-center gap-3 py-4">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<span className="text-sm text-muted-foreground">
Deploying contract...
</span>
</div>
)}
</section>
)}
{/* Success */}
{deployedAddress && (
<section className="rounded-lg border border-green-600/30 bg-green-600/10 p-6 text-center space-y-4">
<p className="text-lg font-medium text-green-500">
Smart Wallet Deployed
</p>
<p className="truncate font-mono text-sm text-green-500/80">
{deployedAddress}
</p>
<button
onClick={() =>
router.push(`/wallets/${params.id}/smart-wallets`)
}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
View Smart Wallets
</button>
</section>
)}
</div>
)
}
@@ -0,0 +1,87 @@
'use client'
import { useState } from 'react'
import { useParams } from 'next/navigation'
import Link from 'next/link'
import { SmartWalletCard } from '@/components/smart-wallets/smart-wallet-card'
export interface SmartWallet {
id: string
type: 'safe' | 'erc4337'
chain: string
chainId: number
contractAddress: string
threshold: number
owners: string[]
status: 'deployed' | 'pending' | 'failed'
}
export default function SmartWalletsPage() {
const params = useParams<{ id: string }>()
// TODO: fetch smart wallets from API
const [wallets] = useState<SmartWallet[]>([
{
id: 'sw-1',
type: 'safe',
chain: 'Ethereum',
chainId: 1,
contractAddress: '0x1234...abcd',
threshold: 2,
owners: ['0xaaa...111', '0xbbb...222', '0xccc...333'],
status: 'deployed',
},
{
id: 'sw-2',
type: 'erc4337',
chain: 'Lux C-Chain',
chainId: 96369,
contractAddress: '0x5678...efgh',
threshold: 1,
owners: ['0xaaa...111'],
status: 'deployed',
},
])
return (
<div className="mx-auto max-w-4xl space-y-8 px-4 py-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">
Smart Wallets
</h1>
<p className="mt-1 text-sm text-muted-foreground">
Smart contract wallets backed by MPC key{' '}
<span className="font-mono">{params.id}</span>
</p>
</div>
<Link
href={`/wallets/${params.id}/smart-wallets/deploy`}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
Deploy Smart Wallet
</Link>
</div>
{wallets.length === 0 ? (
<div className="rounded-lg border border-border bg-card p-12 text-center">
<p className="text-muted-foreground">
No smart wallets deployed yet.
</p>
<Link
href={`/wallets/${params.id}/smart-wallets/deploy`}
className="mt-4 inline-block text-sm font-medium text-primary hover:underline"
>
Deploy your first smart wallet
</Link>
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2">
{wallets.map((sw) => (
<SmartWalletCard key={sw.id} wallet={sw} />
))}
</div>
)}
</div>
)
}
+161
View File
@@ -0,0 +1,161 @@
'use client'
import { useState, useEffect, type FormEvent } from 'react'
import { Nav } from '@/components/layout/nav'
import { api } from '@/lib/api'
import type { AddressWhitelist } from '@/lib/types'
const chains = ['ethereum', 'bitcoin', 'lux', 'solana', 'xrpl', 'zoo', 'hanzo']
export default function WhitelistPage() {
const [entries, setEntries] = useState<AddressWhitelist[]>([])
const [error, setError] = useState('')
// Form state
const [newAddress, setNewAddress] = useState('')
const [newChain, setNewChain] = useState(chains[0])
const [newLabel, setNewLabel] = useState('')
const [adding, setAdding] = useState(false)
useEffect(() => {
api.listWhitelist().then(setEntries).catch((e) => setError(e.message))
}, [])
async function handleAdd(e: FormEvent) {
e.preventDefault()
setAdding(true)
setError('')
try {
const entry = await api.addWhitelist({
address: newAddress,
chain: newChain,
label: newLabel || undefined,
})
setEntries((prev) => [entry, ...prev])
setNewAddress('')
setNewLabel('')
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to add address')
} finally {
setAdding(false)
}
}
async function handleDelete(id: string) {
try {
await api.deleteWhitelist(id)
setEntries((prev) => prev.filter((e) => e.id !== id))
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to delete')
}
}
return (
<>
<Nav />
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<div className="mb-8">
<h1 className="text-2xl font-semibold tracking-tight">Address Whitelist</h1>
<p className="mt-1 text-sm text-muted-foreground">Manage approved addresses for outgoing transactions.</p>
</div>
{error && <p className="mb-4 text-sm text-destructive">{error}</p>}
{/* Add form */}
<form onSubmit={handleAdd} className="mb-8 rounded-lg border border-border bg-card p-6 space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">Add Address</h2>
<div className="grid gap-4 sm:grid-cols-3">
<div className="sm:col-span-2 space-y-1.5">
<label className="text-sm font-medium text-muted-foreground">Address</label>
<input
type="text"
required
value={newAddress}
onChange={(e) => setNewAddress(e.target.value)}
placeholder="0x..."
className="w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-muted-foreground">Chain</label>
<select
value={newChain}
onChange={(e) => setNewChain(e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
{chains.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
</div>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-muted-foreground">Label</label>
<input
type="text"
value={newLabel}
onChange={(e) => setNewLabel(e.target.value)}
placeholder="e.g. Treasury, Exchange, Partner"
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<button
type="submit"
disabled={adding || !newAddress}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{adding ? 'Adding...' : 'Add to Whitelist'}
</button>
</form>
{/* Table */}
<div className="overflow-x-auto rounded-lg border border-border">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-muted/50">
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Address</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Chain</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Label</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Added By</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Date</th>
<th className="px-4 py-3 text-right font-medium text-muted-foreground">Actions</th>
</tr>
</thead>
<tbody>
{entries.map((entry) => (
<tr key={entry.id} className="border-b border-border last:border-0">
<td className="px-4 py-3 font-mono text-xs">
<span className="block max-w-[200px] truncate">{entry.address}</span>
</td>
<td className="px-4 py-3 capitalize">{entry.chain}</td>
<td className="px-4 py-3">{entry.label || '--'}</td>
<td className="px-4 py-3 text-muted-foreground font-mono text-xs">
{entry.created_by || '--'}
</td>
<td className="px-4 py-3 text-muted-foreground">
{new Date(entry.created_at).toLocaleDateString()}
</td>
<td className="px-4 py-3 text-right">
<button
onClick={() => handleDelete(entry.id)}
className="text-sm text-destructive hover:underline"
>
Delete
</button>
</td>
</tr>
))}
{entries.length === 0 && (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-muted-foreground">
No whitelisted addresses yet.
</td>
</tr>
)}
</tbody>
</table>
</div>
</main>
</>
)
}
@@ -0,0 +1,25 @@
const chainMeta: Record<string, { label: string; color: string }> = {
ethereum: { label: 'Ethereum', color: 'text-blue-400' },
bitcoin: { label: 'Bitcoin', color: 'text-amber-400' },
solana: { label: 'Solana', color: 'text-purple-400' },
lux: { label: 'Lux', color: 'text-zinc-300' },
xrpl: { label: 'XRPL', color: 'text-zinc-400' },
ton: { label: 'TON', color: 'text-cyan-400' },
}
interface ChainIconProps {
chain: string
className?: string
}
export function ChainIcon({ chain, className = '' }: ChainIconProps) {
const meta = chainMeta[chain.toLowerCase()] ?? { label: chain, color: 'text-muted-foreground' }
return (
<span className={`inline-flex items-center gap-1.5 text-sm font-medium ${meta.color} ${className}`}>
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-muted text-[10px] font-bold uppercase">
{meta.label.charAt(0)}
</span>
{meta.label}
</span>
)
}
@@ -0,0 +1,73 @@
'use client'
export interface Column<T> {
key: string
header: string
render?: (row: T) => React.ReactNode
className?: string
}
interface DataTableProps<T> {
columns: Column<T>[]
data: T[]
keyField: string
emptyMessage?: string
onRowClick?: (row: T) => void
}
export function DataTable<T extends Record<string, any>>({
columns,
data,
keyField,
emptyMessage = 'No data found.',
onRowClick,
}: DataTableProps<T>) {
return (
<div className="overflow-x-auto rounded-md border border-border">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-muted/50">
{columns.map((col) => (
<th
key={col.key}
className={`px-4 py-3 text-left font-medium text-muted-foreground ${col.className ?? ''}`}
>
{col.header}
</th>
))}
</tr>
</thead>
<tbody>
{data.length === 0 ? (
<tr>
<td
colSpan={columns.length}
className="px-4 py-8 text-center text-muted-foreground"
>
{emptyMessage}
</td>
</tr>
) : (
data.map((row) => (
<tr
key={String(row[keyField])}
onClick={onRowClick ? () => onRowClick(row) : undefined}
className={`border-b border-border transition-colors last:border-0 ${
onRowClick ? 'cursor-pointer hover:bg-muted/50' : ''
}`}
>
{columns.map((col) => (
<td key={col.key} className={`px-4 py-3 ${col.className ?? ''}`}>
{col.render
? col.render(row)
: String(row[col.key] ?? '')}
</td>
))}
</tr>
))
)}
</tbody>
</table>
</div>
)
}
@@ -0,0 +1,33 @@
const statusColors: Record<string, string> = {
active: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20',
running: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20',
signed: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20',
approved: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20',
enabled: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20',
pending: 'bg-amber-500/10 text-amber-400 border-amber-500/20',
pending_approval: 'bg-amber-500/10 text-amber-400 border-amber-500/20',
signing: 'bg-blue-500/10 text-blue-400 border-blue-500/20',
processing: 'bg-blue-500/10 text-blue-400 border-blue-500/20',
failed: 'bg-red-500/10 text-red-400 border-red-500/20',
rejected: 'bg-red-500/10 text-red-400 border-red-500/20',
cancelled: 'bg-red-500/10 text-red-400 border-red-500/20',
disabled: 'bg-zinc-500/10 text-zinc-400 border-zinc-500/20',
paused: 'bg-zinc-500/10 text-zinc-400 border-zinc-500/20',
inactive: 'bg-zinc-500/10 text-zinc-400 border-zinc-500/20',
}
interface StatusBadgeProps {
status: string
className?: string
}
export function StatusBadge({ status, className = '' }: StatusBadgeProps) {
const colorClasses = statusColors[status.toLowerCase()] ?? 'bg-zinc-500/10 text-zinc-400 border-zinc-500/20'
return (
<span
className={`inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium capitalize ${colorClasses} ${className}`}
>
{status.replace(/_/g, ' ')}
</span>
)
}
+52
View File
@@ -0,0 +1,52 @@
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { UserMenu } from './user-menu'
const navLinks = [
{ href: '/' as const, label: 'Dashboard' },
{ href: '/vaults' as const, label: 'Vaults' },
{ href: '/transactions' as const, label: 'Transactions' },
{ href: '/policies' as const, label: 'Policies' },
{ href: '/team' as const, label: 'Team' },
{ href: '/settings' as const, label: 'Settings' },
]
export function Nav() {
const pathname = usePathname()
return (
<header className="sticky top-0 z-50 border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="mx-auto flex h-14 max-w-7xl items-center justify-between px-4 sm:px-6 lg:px-8">
<div className="flex items-center gap-8">
<Link href="/" className="flex items-center gap-2">
<span className="text-lg font-semibold tracking-tight">Lux MPC</span>
</Link>
<nav className="hidden items-center gap-1 md:flex">
{navLinks.map((link) => {
const isActive =
link.href === '/'
? pathname === '/'
: pathname.startsWith(link.href)
return (
<Link
key={link.href}
href={link.href}
className={`rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${
isActive
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
}`}
>
{link.label}
</Link>
)
})}
</nav>
</div>
<UserMenu />
</div>
</header>
)
}
+73
View File
@@ -0,0 +1,73 @@
'use client'
import { useState, useRef, useEffect } from 'react'
import Link from 'next/link'
import { clearTokens } from '@/lib/auth'
export function UserMenu() {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (ref.current && !ref.current.contains(event.target as Node)) {
setOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
const handleLogout = () => {
clearTokens()
window.location.href = '/login'
}
return (
<div ref={ref} className="relative">
<button
onClick={() => setOpen(!open)}
className="flex items-center gap-2 rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
>
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-muted text-xs font-semibold text-muted-foreground">
U
</div>
<span className="hidden sm:inline">user@example.com</span>
<svg
className={`h-4 w-4 transition-transform ${open ? 'rotate-180' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
{open && (
<div className="absolute right-0 mt-1 w-48 rounded-md border border-border bg-card p-1 shadow-lg">
<Link
href="/settings"
onClick={() => setOpen(false)}
className="block rounded-sm px-3 py-2 text-sm text-card-foreground hover:bg-accent"
>
Profile
</Link>
<Link
href="/settings"
onClick={() => setOpen(false)}
className="block rounded-sm px-3 py-2 text-sm text-card-foreground hover:bg-accent"
>
Settings
</Link>
<hr className="my-1 border-border" />
<button
onClick={handleLogout}
className="block w-full rounded-sm px-3 py-2 text-left text-sm text-destructive hover:bg-accent"
>
Logout
</button>
</div>
)}
</div>
)
}
@@ -0,0 +1,195 @@
'use client'
import { useState } from 'react'
const chains = [
{ name: 'Ethereum', chainId: 1 },
{ name: 'Lux C-Chain', chainId: 96369 },
{ name: 'Lux Testnet', chainId: 96368 },
{ name: 'Zoo', chainId: 200200 },
{ name: 'Hanzo', chainId: 36963 },
]
export interface PaymentRequestFormData {
walletId: string
merchantName: string
recipientAddress: string
chain: string
chainId: number
amount: string
memo: string
expiryHours: number
}
export interface PaymentRequestFormProps {
walletId?: string
onSubmit: (data: PaymentRequestFormData) => void
onCancel: () => void
submitting?: boolean
}
export function PaymentRequestForm({
walletId = '',
onSubmit,
onCancel,
submitting = false,
}: PaymentRequestFormProps) {
const [form, setForm] = useState<PaymentRequestFormData>({
walletId,
merchantName: '',
recipientAddress: '',
chain: chains[0].name,
chainId: chains[0].chainId,
amount: '',
memo: '',
expiryHours: 24,
})
function update<K extends keyof PaymentRequestFormData>(
field: K,
value: PaymentRequestFormData[K]
) {
setForm((f) => ({ ...f, [field]: value }))
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
onSubmit(form)
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
{!walletId && (
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="pr-wallet">
Wallet ID (optional)
</label>
<input
id="pr-wallet"
type="text"
value={form.walletId}
onChange={(e) => update('walletId', e.target.value)}
placeholder="Leave empty for any wallet"
className="w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
)}
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="pr-merchant">
Merchant Name
</label>
<input
id="pr-merchant"
type="text"
value={form.merchantName}
onChange={(e) => update('merchantName', e.target.value)}
placeholder="Your business name"
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="pr-recipient">
Recipient Address
</label>
<input
id="pr-recipient"
type="text"
required
value={form.recipientAddress}
onChange={(e) => update('recipientAddress', e.target.value)}
placeholder="0x..."
className="w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="pr-chain">
Chain
</label>
<select
id="pr-chain"
value={form.chainId}
onChange={(e) => {
const chain = chains.find((c) => c.chainId === Number(e.target.value))
if (chain) {
setForm((f) => ({ ...f, chain: chain.name, chainId: chain.chainId }))
}
}}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
{chains.map((c) => (
<option key={c.chainId} value={c.chainId}>
{c.name}
</option>
))}
</select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="pr-amount">
Amount
</label>
<input
id="pr-amount"
type="text"
required
value={form.amount}
onChange={(e) => update('amount', e.target.value)}
placeholder="e.g. 100 USDC"
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="pr-memo">
Memo
</label>
<input
id="pr-memo"
type="text"
value={form.memo}
onChange={(e) => update('memo', e.target.value)}
placeholder="Optional note"
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="pr-expiry">
Expiry (hours)
</label>
<input
id="pr-expiry"
type="number"
min={1}
max={720}
value={form.expiryHours}
onChange={(e) => update('expiryHours', Number(e.target.value))}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={onCancel}
disabled={submitting}
className="flex-1 rounded-md border border-border px-4 py-2 text-sm font-medium hover:bg-accent disabled:opacity-50"
>
Cancel
</button>
<button
type="submit"
disabled={submitting || !form.recipientAddress || !form.amount}
className="flex-1 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{submitting ? 'Creating...' : 'Create Request'}
</button>
</div>
</form>
)
}
@@ -0,0 +1,74 @@
import Link from 'next/link'
export interface PaymentRequest {
id: string
merchantName: string
recipientAddress: string
chain: string
amount: string
status: 'open' | 'paid' | 'expired' | 'cancelled'
expiresAt: string
createdAt: string
}
export interface PaymentRequestTableProps {
requests: PaymentRequest[]
}
const statusColors: Record<string, string> = {
open: 'bg-blue-600/20 text-blue-400',
paid: 'bg-green-600/20 text-green-500',
expired: 'bg-zinc-600/20 text-zinc-400',
cancelled: 'bg-red-600/20 text-red-400',
}
export function PaymentRequestTable({ requests }: PaymentRequestTableProps) {
if (requests.length === 0) {
return (
<div className="rounded-lg border border-border p-8 text-center text-sm text-muted-foreground">
No payment requests found.
</div>
)
}
return (
<div className="overflow-x-auto rounded-lg border border-border">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-muted/50">
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Merchant</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Recipient</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Chain</th>
<th className="px-4 py-3 text-right font-medium text-muted-foreground">Amount</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Status</th>
<th className="px-4 py-3 text-right font-medium text-muted-foreground">Expires</th>
</tr>
</thead>
<tbody>
{requests.map((req) => (
<tr key={req.id} className="border-b border-border last:border-0 hover:bg-muted/20">
<td className="px-4 py-3">
<Link href={`/payments/${req.id}`} className="font-medium hover:underline">
{req.merchantName || 'Unnamed'}
</Link>
</td>
<td className="px-4 py-3 font-mono text-xs">
<span className="truncate block max-w-[140px]">{req.recipientAddress}</span>
</td>
<td className="px-4 py-3">{req.chain}</td>
<td className="px-4 py-3 text-right font-mono">{req.amount}</td>
<td className="px-4 py-3">
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${statusColors[req.status] ?? ''}`}>
{req.status}
</span>
</td>
<td className="px-4 py-3 text-right text-muted-foreground">
{new Date(req.expiresAt).toLocaleString()}
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
@@ -0,0 +1,207 @@
'use client'
import { useState } from 'react'
export type PolicyType = 'spend_limit' | 'whitelist' | 'time_lock' | 'quorum' | 'custom'
export interface PolicyCondition {
field: string
operator: string
value: string
}
export interface PolicyFormData {
name: string
type: PolicyType
conditions: PolicyCondition[]
appliedTo: string
}
export interface PolicyFormProps {
initialData?: Partial<PolicyFormData>
onSubmit: (data: PolicyFormData) => void
onCancel: () => void
submitting?: boolean
}
const policyTypes: { value: PolicyType; label: string }[] = [
{ value: 'spend_limit', label: 'Spend Limit' },
{ value: 'whitelist', label: 'Whitelist' },
{ value: 'time_lock', label: 'Time Lock' },
{ value: 'quorum', label: 'Quorum' },
{ value: 'custom', label: 'Custom' },
]
const operators = ['==', '!=', '>', '<', '>=', '<=', 'in', 'not_in']
const conditionFields: Record<PolicyType, string[]> = {
spend_limit: ['amount', 'token', 'period'],
whitelist: ['to_address', 'chain'],
time_lock: ['delay_seconds', 'window_start', 'window_end'],
quorum: ['required_approvals', 'approver_group'],
custom: ['field'],
}
export function PolicyForm({
initialData,
onSubmit,
onCancel,
submitting = false,
}: PolicyFormProps) {
const [name, setName] = useState(initialData?.name ?? '')
const [type, setType] = useState<PolicyType>(initialData?.type ?? 'spend_limit')
const [conditions, setConditions] = useState<PolicyCondition[]>(
initialData?.conditions ?? [{ field: '', operator: '==', value: '' }]
)
const [appliedTo, setAppliedTo] = useState(initialData?.appliedTo ?? '')
function addCondition() {
setConditions((c) => [...c, { field: '', operator: '==', value: '' }])
}
function updateCondition(index: number, patch: Partial<PolicyCondition>) {
setConditions((c) =>
c.map((cond, i) => (i === index ? { ...cond, ...patch } : cond))
)
}
function removeCondition(index: number) {
setConditions((c) => c.filter((_, i) => i !== index))
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
onSubmit({ name, type, conditions, appliedTo })
}
const fields = conditionFields[type]
return (
<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="policy-name">
Policy Name
</label>
<input
id="policy-name"
type="text"
required
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Daily Spend Limit"
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="policy-type">
Type
</label>
<select
id="policy-type"
value={type}
onChange={(e) => setType(e.target.value as PolicyType)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
{policyTypes.map((pt) => (
<option key={pt.value} value={pt.value}>
{pt.label}
</option>
))}
</select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="policy-applied">
Applied To (wallet or vault ID)
</label>
<input
id="policy-applied"
type="text"
value={appliedTo}
onChange={(e) => setAppliedTo(e.target.value)}
placeholder="wallet-... or vault-... or * for all"
className="w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<label className="text-sm font-medium">Conditions</label>
<button
type="button"
onClick={addCondition}
className="text-sm font-medium text-primary hover:underline"
>
+ Add Condition
</button>
</div>
{conditions.map((cond, i) => (
<div key={i} className="flex items-start gap-2">
<select
value={cond.field}
onChange={(e) => updateCondition(i, { field: e.target.value })}
className="w-1/3 rounded-md border border-input bg-background px-2 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="">Field...</option>
{fields.map((f) => (
<option key={f} value={f}>
{f}
</option>
))}
</select>
<select
value={cond.operator}
onChange={(e) => updateCondition(i, { operator: e.target.value })}
className="w-24 rounded-md border border-input bg-background px-2 py-2 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
{operators.map((op) => (
<option key={op} value={op}>
{op}
</option>
))}
</select>
<input
type="text"
value={cond.value}
onChange={(e) => updateCondition(i, { value: e.target.value })}
placeholder="Value"
className="flex-1 rounded-md border border-input bg-background px-2 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
{conditions.length > 1 && (
<button
type="button"
onClick={() => removeCondition(i)}
className="rounded-md border border-border px-2 py-2 text-sm text-muted-foreground hover:bg-accent"
>
Remove
</button>
)}
</div>
))}
</div>
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={onCancel}
disabled={submitting}
className="flex-1 rounded-md border border-border px-4 py-2 text-sm font-medium hover:bg-accent disabled:opacity-50"
>
Cancel
</button>
<button
type="submit"
disabled={submitting || !name}
className="flex-1 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{submitting ? 'Saving...' : 'Save Policy'}
</button>
</div>
</form>
)
}
@@ -0,0 +1,84 @@
import Link from 'next/link'
export interface Policy {
id: string
name: string
type: 'spend_limit' | 'whitelist' | 'time_lock' | 'quorum' | 'custom'
status: 'active' | 'disabled'
conditions: string
appliedTo: string
createdAt: string
}
export interface PolicyTableProps {
policies: Policy[]
}
const typeLabels: Record<string, string> = {
spend_limit: 'Spend Limit',
whitelist: 'Whitelist',
time_lock: 'Time Lock',
quorum: 'Quorum',
custom: 'Custom',
}
export function PolicyTable({ policies }: PolicyTableProps) {
if (policies.length === 0) {
return (
<div className="rounded-lg border border-border p-8 text-center text-sm text-muted-foreground">
No policies configured.
</div>
)
}
return (
<div className="overflow-x-auto rounded-lg border border-border">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-muted/50">
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Name</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Type</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Conditions</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Applied To</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Status</th>
<th className="px-4 py-3 text-right font-medium text-muted-foreground">Created</th>
</tr>
</thead>
<tbody>
{policies.map((policy) => (
<tr key={policy.id} className="border-b border-border last:border-0 hover:bg-muted/20">
<td className="px-4 py-3">
<Link href={`/policies/${policy.id}`} className="font-medium hover:underline">
{policy.name}
</Link>
</td>
<td className="px-4 py-3">
<span className="rounded-full bg-muted px-2 py-0.5 text-xs">
{typeLabels[policy.type] ?? policy.type}
</span>
</td>
<td className="px-4 py-3 text-muted-foreground max-w-[200px] truncate">
{policy.conditions}
</td>
<td className="px-4 py-3 font-mono text-xs">{policy.appliedTo}</td>
<td className="px-4 py-3">
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
policy.status === 'active'
? 'bg-green-600/20 text-green-500'
: 'bg-zinc-600/20 text-zinc-400'
}`}
>
{policy.status}
</span>
</td>
<td className="px-4 py-3 text-right text-muted-foreground">
{new Date(policy.createdAt).toLocaleDateString()}
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
@@ -0,0 +1,48 @@
interface DeployWizardProps {
currentStep: 'type' | 'config' | 'advanced' | 'deploy'
}
const steps = [
{ key: 'type', label: 'Type' },
{ key: 'config', label: 'Config' },
{ key: 'advanced', label: 'Advanced' },
{ key: 'deploy', label: 'Deploy' },
] as const
export function DeployWizard({ currentStep }: DeployWizardProps) {
const currentIndex = steps.findIndex((s) => s.key === currentStep)
return (
<div className="flex items-center justify-between">
{steps.map((step, i) => (
<div key={step.key} className="flex items-center gap-2">
<div
className={`flex h-8 w-8 items-center justify-center rounded-full text-xs font-semibold ${
step.key === currentStep
? 'bg-primary text-primary-foreground'
: i < currentIndex
? 'bg-emerald-500/20 text-emerald-400'
: 'bg-muted text-muted-foreground'
}`}
>
{i < currentIndex ? (
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
) : (
i + 1
)}
</div>
<span
className={`hidden text-xs font-medium sm:inline ${
step.key === currentStep ? 'text-foreground' : 'text-muted-foreground'
}`}
>
{step.label}
</span>
{i < steps.length - 1 && <div className="mx-2 h-px w-8 bg-border sm:w-12" />}
</div>
))}
</div>
)
}
@@ -0,0 +1,102 @@
'use client'
interface PendingSafeTx {
id: string
to: string
value: string
data: string
nonce: number
confirmations: number
required: number
submittedBy: string
createdAt: string
}
interface SafeTxQueueProps {
transactions: PendingSafeTx[]
walletId: string
}
export function SafeTxQueue({ transactions, walletId }: SafeTxQueueProps) {
if (transactions.length === 0) {
return (
<div className="rounded-lg border border-border p-8 text-center text-sm text-muted-foreground">
No pending transactions.
</div>
)
}
async function handleConfirm(txId: string) {
// TODO: call API to confirm Safe transaction
console.log('Confirm tx', txId, 'on wallet', walletId)
}
async function handleExecute(txId: string) {
// TODO: call API to execute Safe transaction
console.log('Execute tx', txId, 'on wallet', walletId)
}
return (
<div className="space-y-3">
{transactions.map((tx) => (
<div
key={tx.id}
className="rounded-lg border border-border bg-card p-4 space-y-3"
>
<div className="flex items-center justify-between">
<span className="font-mono text-xs text-muted-foreground">
Nonce #{tx.nonce}
</span>
<span className="text-xs text-muted-foreground">
{tx.confirmations}/{tx.required} confirmations
</span>
</div>
<div className="grid grid-cols-2 gap-2 text-sm">
<div>
<p className="text-xs text-muted-foreground">To</p>
<p className="truncate font-mono text-xs">{tx.to}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Value</p>
<p className="font-mono text-xs">{tx.value}</p>
</div>
</div>
{tx.data !== '0x' && (
<div>
<p className="text-xs text-muted-foreground">Data</p>
<p className="truncate font-mono text-xs">{tx.data}</p>
</div>
)}
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">
By: {tx.submittedBy} at {new Date(tx.createdAt).toLocaleString()}
</span>
<div className="flex gap-2">
<button
onClick={() => handleConfirm(tx.id)}
className="rounded-md border border-border px-3 py-1.5 text-xs font-medium text-muted-foreground hover:bg-accent"
>
Confirm
</button>
{tx.confirmations >= tx.required && (
<button
onClick={() => handleExecute(tx.id)}
className="rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90"
>
Execute
</button>
)}
</div>
</div>
{/* Progress bar */}
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-emerald-500 transition-all"
style={{ width: `${(tx.confirmations / tx.required) * 100}%` }}
/>
</div>
</div>
))}
</div>
)
}
@@ -0,0 +1,44 @@
import Link from 'next/link'
import { StatusBadge } from '@/components/common/status-badge'
interface SmartWallet {
id: string
type: 'safe' | 'erc4337'
chain: string
chainId: number
contractAddress: string
threshold: number
owners: string[]
status: 'deployed' | 'pending' | 'failed'
}
interface SmartWalletCardProps {
wallet: SmartWallet
}
export function SmartWalletCard({ wallet }: SmartWalletCardProps) {
return (
<Link
href={`/smart-wallets/${wallet.id}`}
className="group rounded-lg border border-border bg-card p-6 transition-colors hover:border-foreground/20"
>
<div className="flex items-center justify-between">
<span className="text-sm font-semibold">
{wallet.type === 'safe' ? 'Safe' : 'ERC-4337'}
</span>
<StatusBadge status={wallet.status === 'deployed' ? 'active' : wallet.status} />
</div>
<p className="mt-2 truncate font-mono text-xs text-muted-foreground">
{wallet.contractAddress}
</p>
<div className="mt-4 flex items-center justify-between text-xs text-muted-foreground">
<span>
{wallet.chain} ({wallet.chainId})
</span>
<span className="font-mono">
{wallet.threshold}-of-{wallet.owners.length}
</span>
</div>
</Link>
)
}
@@ -0,0 +1,61 @@
export interface PaymentRecord {
id: string
txHash: string
amount: string
token: string
status: 'confirmed' | 'pending' | 'failed'
paidAt: string
}
export interface PaymentHistoryProps {
payments: PaymentRecord[]
}
const statusColors: Record<string, string> = {
confirmed: 'bg-green-600/20 text-green-500',
pending: 'bg-yellow-600/20 text-yellow-500',
failed: 'bg-red-600/20 text-red-400',
}
export function PaymentHistory({ payments }: PaymentHistoryProps) {
if (payments.length === 0) {
return (
<div className="rounded-lg border border-border p-8 text-center text-sm text-muted-foreground">
No payments recorded yet.
</div>
)
}
return (
<div className="overflow-x-auto rounded-lg border border-border">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-muted/50">
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Date</th>
<th className="px-4 py-3 text-right font-medium text-muted-foreground">Amount</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Status</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Tx Hash</th>
</tr>
</thead>
<tbody>
{payments.map((p) => (
<tr key={p.id} className="border-b border-border last:border-0 hover:bg-muted/20">
<td className="px-4 py-3 text-muted-foreground">
{new Date(p.paidAt).toLocaleDateString()}
</td>
<td className="px-4 py-3 text-right font-mono">{p.amount}</td>
<td className="px-4 py-3">
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${statusColors[p.status] ?? ''}`}>
{p.status}
</span>
</td>
<td className="px-4 py-3 font-mono text-xs">
<span className="truncate block max-w-[200px]">{p.txHash}</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
@@ -0,0 +1,224 @@
'use client'
import { useState } from 'react'
const chains = [
{ name: 'Ethereum', chainId: 1 },
{ name: 'Lux C-Chain', chainId: 96369 },
{ name: 'Lux Testnet', chainId: 96368 },
{ name: 'Zoo', chainId: 200200 },
{ name: 'Hanzo', chainId: 36963 },
]
const intervals = ['daily', 'weekly', 'monthly', 'quarterly', 'yearly']
export interface SubscriptionFormData {
name: string
provider: string
walletId: string
recipientAddress: string
chain: string
chainId: number
amount: string
token: string
interval: string
}
export interface SubscriptionFormProps {
walletId?: string
onSubmit: (data: SubscriptionFormData) => void
onCancel: () => void
submitting?: boolean
}
export function SubscriptionForm({
walletId = '',
onSubmit,
onCancel,
submitting = false,
}: SubscriptionFormProps) {
const [form, setForm] = useState<SubscriptionFormData>({
name: '',
provider: '',
walletId,
recipientAddress: '',
chain: chains[0].name,
chainId: chains[0].chainId,
amount: '',
token: 'USDC',
interval: 'monthly',
})
function update<K extends keyof SubscriptionFormData>(
field: K,
value: SubscriptionFormData[K]
) {
setForm((f) => ({ ...f, [field]: value }))
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
onSubmit(form)
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="sub-name">
Subscription Name
</label>
<input
id="sub-name"
type="text"
required
value={form.name}
onChange={(e) => update('name', e.target.value)}
placeholder="e.g. Cloud Hosting"
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="sub-provider">
Provider
</label>
<input
id="sub-provider"
type="text"
required
value={form.provider}
onChange={(e) => update('provider', e.target.value)}
placeholder="Provider name"
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
</div>
{!walletId && (
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="sub-wallet">
Wallet ID
</label>
<input
id="sub-wallet"
type="text"
required
value={form.walletId}
onChange={(e) => update('walletId', e.target.value)}
placeholder="wallet-..."
className="w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
)}
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="sub-recipient">
Recipient Address
</label>
<input
id="sub-recipient"
type="text"
required
value={form.recipientAddress}
onChange={(e) => update('recipientAddress', e.target.value)}
placeholder="0x..."
className="w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="sub-chain">
Chain
</label>
<select
id="sub-chain"
value={form.chainId}
onChange={(e) => {
const chain = chains.find((c) => c.chainId === Number(e.target.value))
if (chain) {
setForm((f) => ({ ...f, chain: chain.name, chainId: chain.chainId }))
}
}}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
{chains.map((c) => (
<option key={c.chainId} value={c.chainId}>
{c.name}
</option>
))}
</select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="sub-interval">
Interval
</label>
<select
id="sub-interval"
value={form.interval}
onChange={(e) => update('interval', e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
{intervals.map((i) => (
<option key={i} value={i}>
{i.charAt(0).toUpperCase() + i.slice(1)}
</option>
))}
</select>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="sub-amount">
Amount
</label>
<input
id="sub-amount"
type="text"
required
value={form.amount}
onChange={(e) => update('amount', e.target.value)}
placeholder="e.g. 100"
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="sub-token">
Token
</label>
<input
id="sub-token"
type="text"
required
value={form.token}
onChange={(e) => update('token', e.target.value)}
placeholder="e.g. USDC, ETH"
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
</div>
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={onCancel}
disabled={submitting}
className="flex-1 rounded-md border border-border px-4 py-2 text-sm font-medium hover:bg-accent disabled:opacity-50"
>
Cancel
</button>
<button
type="submit"
disabled={submitting || !form.name || !form.recipientAddress || !form.amount}
className="flex-1 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{submitting ? 'Creating...' : 'Create Subscription'}
</button>
</div>
</form>
)
}
@@ -0,0 +1,78 @@
import Link from 'next/link'
export interface SubscriptionRow {
id: string
name: string
provider: string
chain: string
amount: string
token: string
interval: string
status: 'active' | 'paused' | 'cancelled'
nextPaymentAt: string
}
export interface SubscriptionTableProps {
subscriptions: SubscriptionRow[]
}
const statusColors: Record<string, string> = {
active: 'bg-green-600/20 text-green-500',
paused: 'bg-yellow-600/20 text-yellow-500',
cancelled: 'bg-zinc-600/20 text-zinc-400',
}
export function SubscriptionTable({ subscriptions }: SubscriptionTableProps) {
if (subscriptions.length === 0) {
return (
<div className="rounded-lg border border-border p-8 text-center text-sm text-muted-foreground">
No subscriptions found.
</div>
)
}
return (
<div className="overflow-x-auto rounded-lg border border-border">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-muted/50">
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Name</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Provider</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Chain</th>
<th className="px-4 py-3 text-right font-medium text-muted-foreground">Amount</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Interval</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Status</th>
<th className="px-4 py-3 text-right font-medium text-muted-foreground">Next Payment</th>
</tr>
</thead>
<tbody>
{subscriptions.map((sub) => (
<tr key={sub.id} className="border-b border-border last:border-0 hover:bg-muted/20">
<td className="px-4 py-3">
<Link href={`/subscriptions/${sub.id}`} className="font-medium hover:underline">
{sub.name}
</Link>
</td>
<td className="px-4 py-3 text-muted-foreground">{sub.provider}</td>
<td className="px-4 py-3">{sub.chain}</td>
<td className="px-4 py-3 text-right font-mono">
{sub.amount} {sub.token}
</td>
<td className="px-4 py-3 capitalize">{sub.interval}</td>
<td className="px-4 py-3">
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${statusColors[sub.status] ?? ''}`}>
{sub.status}
</span>
</td>
<td className="px-4 py-3 text-right text-muted-foreground">
{sub.status !== 'cancelled'
? new Date(sub.nextPaymentAt).toLocaleDateString()
: '-'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
@@ -0,0 +1,92 @@
'use client'
import { useState } from 'react'
export interface ApprovalPanelProps {
transactionId: string
status: 'awaiting_approval' | 'pending' | 'confirmed' | 'failed'
requiredApprovals: number
currentApprovals: number
canApprove: boolean
onApprove: (transactionId: string) => Promise<void>
onReject: (transactionId: string) => Promise<void>
}
export function ApprovalPanel({
transactionId,
status,
requiredApprovals,
currentApprovals,
canApprove,
onApprove,
onReject,
}: ApprovalPanelProps) {
const [acting, setActing] = useState(false)
async function handleApprove() {
setActing(true)
try {
await onApprove(transactionId)
} finally {
setActing(false)
}
}
async function handleReject() {
setActing(true)
try {
await onReject(transactionId)
} finally {
setActing(false)
}
}
if (status !== 'awaiting_approval') {
return null
}
return (
<div className="rounded-lg border border-border bg-card p-4 space-y-4">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Approvals</span>
<span className="font-mono">
{currentApprovals} / {requiredApprovals}
</span>
</div>
<div className="h-1.5 w-full rounded-full bg-muted">
<div
className="h-1.5 rounded-full bg-primary transition-all"
style={{
width: `${Math.min(100, (currentApprovals / requiredApprovals) * 100)}%`,
}}
/>
</div>
{canApprove && (
<div className="flex gap-3">
<button
onClick={handleReject}
disabled={acting}
className="flex-1 rounded-md border border-destructive/30 px-4 py-2 text-sm font-medium text-destructive hover:bg-destructive/10 disabled:opacity-50"
>
Reject
</button>
<button
onClick={handleApprove}
disabled={acting}
className="flex-1 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{acting ? 'Submitting...' : 'Approve'}
</button>
</div>
)}
{!canApprove && (
<p className="text-center text-xs text-muted-foreground">
You have already submitted your approval for this transaction.
</p>
)}
</div>
)
}
@@ -0,0 +1,142 @@
export interface TxDetailData {
id: string
type: string
status: string
chain: string
chainId: number
from: string
to: string
value: string
hash?: string
data?: string
nonce?: number
gasUsed?: string
gasPrice?: string
blockNumber?: number
memo?: string
createdAt: string
confirmedAt?: string
approvals?: { address: string; approved: boolean; timestamp: string }[]
}
export interface TxDetailProps {
transaction: TxDetailData
}
const statusColors: Record<string, string> = {
pending: 'bg-yellow-600/20 text-yellow-500',
confirmed: 'bg-green-600/20 text-green-500',
failed: 'bg-red-600/20 text-red-400',
awaiting_approval: 'bg-blue-600/20 text-blue-400',
}
export function TxDetail({ transaction: tx }: TxDetailProps) {
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<span
className={`rounded-full px-3 py-1 text-xs font-medium ${statusColors[tx.status] ?? 'bg-muted text-muted-foreground'}`}
>
{tx.status.replace('_', ' ')}
</span>
<span className="text-sm text-muted-foreground">{tx.type}</span>
</div>
<dl className="grid grid-cols-1 gap-4 text-sm sm:grid-cols-2">
<div>
<dt className="text-muted-foreground">Chain</dt>
<dd className="mt-1">
{tx.chain} ({tx.chainId})
</dd>
</div>
<div>
<dt className="text-muted-foreground">Transaction ID</dt>
<dd className="mt-1 font-mono text-xs">{tx.id}</dd>
</div>
<div className="sm:col-span-2">
<dt className="text-muted-foreground">From</dt>
<dd className="mt-1 truncate font-mono text-xs">{tx.from}</dd>
</div>
<div className="sm:col-span-2">
<dt className="text-muted-foreground">To</dt>
<dd className="mt-1 truncate font-mono text-xs">{tx.to}</dd>
</div>
<div>
<dt className="text-muted-foreground">Value</dt>
<dd className="mt-1 font-mono">{tx.value}</dd>
</div>
{tx.hash && (
<div>
<dt className="text-muted-foreground">Tx Hash</dt>
<dd className="mt-1 truncate font-mono text-xs">{tx.hash}</dd>
</div>
)}
{tx.nonce != null && (
<div>
<dt className="text-muted-foreground">Nonce</dt>
<dd className="mt-1 font-mono">{tx.nonce}</dd>
</div>
)}
{tx.blockNumber != null && (
<div>
<dt className="text-muted-foreground">Block</dt>
<dd className="mt-1 font-mono">{tx.blockNumber}</dd>
</div>
)}
{tx.gasUsed && (
<div>
<dt className="text-muted-foreground">Gas Used</dt>
<dd className="mt-1 font-mono">{tx.gasUsed}</dd>
</div>
)}
{tx.gasPrice && (
<div>
<dt className="text-muted-foreground">Gas Price</dt>
<dd className="mt-1 font-mono">{tx.gasPrice}</dd>
</div>
)}
{tx.data && (
<div className="sm:col-span-2">
<dt className="text-muted-foreground">Calldata</dt>
<dd className="mt-1 break-all font-mono text-xs">{tx.data}</dd>
</div>
)}
{tx.memo && (
<div className="sm:col-span-2">
<dt className="text-muted-foreground">Memo</dt>
<dd className="mt-1">{tx.memo}</dd>
</div>
)}
<div>
<dt className="text-muted-foreground">Created</dt>
<dd className="mt-1">{new Date(tx.createdAt).toLocaleString()}</dd>
</div>
{tx.confirmedAt && (
<div>
<dt className="text-muted-foreground">Confirmed</dt>
<dd className="mt-1">{new Date(tx.confirmedAt).toLocaleString()}</dd>
</div>
)}
</dl>
{tx.approvals && tx.approvals.length > 0 && (
<div className="space-y-2">
<h3 className="text-sm font-medium text-muted-foreground">Approvals</h3>
<div className="space-y-1">
{tx.approvals.map((a) => (
<div
key={a.address}
className="flex items-center justify-between rounded-md border border-border px-3 py-2 text-xs"
>
<span className="truncate font-mono">{a.address}</span>
<span className={a.approved ? 'text-green-500' : 'text-yellow-500'}>
{a.approved ? 'Approved' : 'Pending'}
</span>
</div>
))}
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,170 @@
'use client'
import { useState } from 'react'
const chains = [
{ name: 'Ethereum', chainId: 1 },
{ name: 'Lux C-Chain', chainId: 96369 },
{ name: 'Lux Testnet', chainId: 96368 },
{ name: 'Zoo', chainId: 200200 },
{ name: 'Hanzo', chainId: 36963 },
{ name: 'Bitcoin', chainId: 0 },
]
export interface TxFormData {
walletId: string
chain: string
chainId: number
to: string
value: string
data: string
memo: string
}
export interface TxFormProps {
walletId?: string
onSubmit: (data: TxFormData) => void
onCancel: () => void
submitting?: boolean
}
export function TxForm({ walletId = '', onSubmit, onCancel, submitting = false }: TxFormProps) {
const [form, setForm] = useState<TxFormData>({
walletId,
chain: chains[0].name,
chainId: chains[0].chainId,
to: '',
value: '',
data: '',
memo: '',
})
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
onSubmit(form)
}
function update(field: keyof TxFormData, value: string | number) {
setForm((f) => ({ ...f, [field]: value }))
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
{!walletId && (
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="tx-wallet">
Wallet ID
</label>
<input
id="tx-wallet"
type="text"
required
value={form.walletId}
onChange={(e) => update('walletId', e.target.value)}
placeholder="wallet-..."
className="w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
)}
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="tx-chain">
Chain
</label>
<select
id="tx-chain"
value={form.chainId}
onChange={(e) => {
const chain = chains.find((c) => c.chainId === Number(e.target.value))
if (chain) {
setForm((f) => ({ ...f, chain: chain.name, chainId: chain.chainId }))
}
}}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
{chains.map((c) => (
<option key={c.chainId} value={c.chainId}>
{c.name}
</option>
))}
</select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="tx-to">
Recipient Address
</label>
<input
id="tx-to"
type="text"
required
value={form.to}
onChange={(e) => update('to', e.target.value)}
placeholder="0x..."
className="w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="tx-value">
Value
</label>
<input
id="tx-value"
type="text"
required
value={form.value}
onChange={(e) => update('value', e.target.value)}
placeholder="e.g. 1.5"
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="tx-data">
Calldata (optional)
</label>
<textarea
id="tx-data"
value={form.data}
onChange={(e) => update('data', e.target.value)}
placeholder="0x..."
rows={3}
className="w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="tx-memo">
Memo
</label>
<input
id="tx-memo"
type="text"
value={form.memo}
onChange={(e) => update('memo', e.target.value)}
placeholder="Optional note"
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={onCancel}
disabled={submitting}
className="flex-1 rounded-md border border-border px-4 py-2 text-sm font-medium hover:bg-accent disabled:opacity-50"
>
Cancel
</button>
<button
type="submit"
disabled={submitting || !form.to || !form.value}
className="flex-1 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{submitting ? 'Submitting...' : 'Create Transaction'}
</button>
</div>
</form>
)
}
@@ -0,0 +1,79 @@
import Link from 'next/link'
export interface Transaction {
id: string
type: string
status: 'pending' | 'confirmed' | 'failed' | 'awaiting_approval'
chain: string
from: string
to: string
value: string
hash?: string
timestamp: string
}
export interface TxTableProps {
transactions: Transaction[]
}
const statusColors: Record<string, string> = {
pending: 'bg-yellow-600/20 text-yellow-500',
confirmed: 'bg-green-600/20 text-green-500',
failed: 'bg-red-600/20 text-red-400',
awaiting_approval: 'bg-blue-600/20 text-blue-400',
}
export function TxTable({ transactions }: TxTableProps) {
if (transactions.length === 0) {
return (
<div className="rounded-lg border border-border p-8 text-center text-sm text-muted-foreground">
No transactions found.
</div>
)
}
return (
<div className="overflow-x-auto rounded-lg border border-border">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-muted/50">
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Type</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Status</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Chain</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">From</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">To</th>
<th className="px-4 py-3 text-right font-medium text-muted-foreground">Value</th>
<th className="px-4 py-3 text-right font-medium text-muted-foreground">Time</th>
</tr>
</thead>
<tbody>
{transactions.map((tx) => (
<tr key={tx.id} className="border-b border-border last:border-0 hover:bg-muted/20">
<td className="px-4 py-3">
<Link href={`/transactions/${tx.id}`} className="font-medium hover:underline">
{tx.type}
</Link>
</td>
<td className="px-4 py-3">
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${statusColors[tx.status] ?? ''}`}>
{tx.status.replace('_', ' ')}
</span>
</td>
<td className="px-4 py-3">{tx.chain}</td>
<td className="px-4 py-3 font-mono text-xs">
<span className="truncate block max-w-[120px]">{tx.from}</span>
</td>
<td className="px-4 py-3 font-mono text-xs">
<span className="truncate block max-w-[120px]">{tx.to}</span>
</td>
<td className="px-4 py-3 text-right font-mono">{tx.value}</td>
<td className="px-4 py-3 text-right text-muted-foreground">
{new Date(tx.timestamp).toLocaleString()}
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
@@ -0,0 +1,29 @@
'use client'
import Link from 'next/link'
export interface VaultCardProps {
id: string
name: string
description: string
walletCount: number
}
export function VaultCard({ id, name, description, walletCount }: VaultCardProps) {
return (
<Link
href={`/vaults/${id}`}
className="block rounded-lg border border-border bg-card p-6 transition-colors hover:border-muted-foreground/30 hover:bg-accent/50"
>
<h3 className="text-sm font-semibold">{name}</h3>
{description && (
<p className="mt-1 text-xs text-muted-foreground line-clamp-2">
{description}
</p>
)}
<p className="mt-3 text-xs text-muted-foreground">
{walletCount} wallet{walletCount !== 1 ? 's' : ''}
</p>
</Link>
)
}
@@ -0,0 +1,95 @@
'use client'
import { useState } from 'react'
export interface VaultFormProps {
initialName?: string
initialDescription?: string
initialAppId?: string
onSubmit: (data: { name: string; description: string; appId: string }) => void
onCancel: () => void
submitting?: boolean
}
export function VaultForm({
initialName = '',
initialDescription = '',
initialAppId = '',
onSubmit,
onCancel,
submitting = false,
}: VaultFormProps) {
const [name, setName] = useState(initialName)
const [description, setDescription] = useState(initialDescription)
const [appId, setAppId] = useState(initialAppId)
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
onSubmit({ name, description, appId })
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="vault-name">
Name
</label>
<input
id="vault-name"
type="text"
required
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Vault name"
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="vault-desc">
Description
</label>
<textarea
id="vault-desc"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Optional description"
rows={3}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="vault-app-id">
App ID
</label>
<input
id="vault-app-id"
type="text"
value={appId}
onChange={(e) => setAppId(e.target.value)}
placeholder="Application identifier"
className="w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={onCancel}
disabled={submitting}
className="flex-1 rounded-md border border-border px-4 py-2 text-sm font-medium hover:bg-accent disabled:opacity-50"
>
Cancel
</button>
<button
type="submit"
disabled={submitting || !name}
className="flex-1 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{submitting ? 'Saving...' : 'Save'}
</button>
</div>
</form>
)
}
@@ -0,0 +1,50 @@
export interface ChainAddress {
chain: string
chainId?: number
address: string
format?: string
}
export interface AddressListProps {
addresses: ChainAddress[]
}
export function AddressList({ addresses }: AddressListProps) {
if (addresses.length === 0) {
return (
<p className="text-sm text-muted-foreground">No derived addresses.</p>
)
}
return (
<div className="space-y-2">
{addresses.map((a) => (
<div
key={`${a.chain}-${a.address}`}
className="flex items-center justify-between rounded-md border border-border bg-muted/30 px-3 py-2"
>
<div className="min-w-0 flex-1">
<p className="text-xs font-medium">
{a.chain}
{a.chainId != null && (
<span className="ml-1 text-muted-foreground">({a.chainId})</span>
)}
{a.format && (
<span className="ml-2 text-muted-foreground">[{a.format}]</span>
)}
</p>
<p className="mt-0.5 truncate font-mono text-xs text-muted-foreground">
{a.address}
</p>
</div>
<button
onClick={() => navigator.clipboard.writeText(a.address)}
className="ml-2 shrink-0 rounded px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-accent-foreground"
>
Copy
</button>
</div>
))}
</div>
)
}
@@ -0,0 +1,56 @@
export interface Balance {
chain: string
token: string
amount: string
usdValue?: string
}
export interface BalanceDisplayProps {
balances: Balance[]
loading?: boolean
}
export function BalanceDisplay({ balances, loading = false }: BalanceDisplayProps) {
if (loading) {
return (
<div className="space-y-2">
{[0, 1, 2].map((i) => (
<div
key={i}
className="h-10 animate-pulse rounded-md bg-muted"
/>
))}
</div>
)
}
if (balances.length === 0) {
return (
<p className="text-sm text-muted-foreground">No balances found.</p>
)
}
return (
<div className="space-y-1">
{balances.map((b) => (
<div
key={`${b.chain}-${b.token}`}
className="flex items-center justify-between rounded-md px-3 py-2 text-sm hover:bg-muted/30"
>
<div>
<span className="font-medium">{b.token}</span>
<span className="ml-2 text-xs text-muted-foreground">{b.chain}</span>
</div>
<div className="text-right">
<span className="font-mono">{b.amount}</span>
{b.usdValue && (
<span className="ml-2 text-xs text-muted-foreground">
${b.usdValue}
</span>
)}
</div>
</div>
))}
</div>
)
}
@@ -0,0 +1,91 @@
'use client'
export interface KeygenProgressProps {
status: 'waiting' | 'round1' | 'round2' | 'round3' | 'round4' | 'finalizing' | 'complete' | 'error'
protocol: 'cggmp21' | 'frost' | 'lss'
participants: number
readyCount?: number
error?: string
}
const roundLabels: Record<string, string[]> = {
cggmp21: ['Waiting for peers', 'Round 1: Commitments', 'Round 2: Shares', 'Round 3: Proofs', 'Round 4: Verification', 'Finalizing', 'Complete'],
frost: ['Waiting for peers', 'Round 1: Commitments', 'Round 2: Shares', 'Verification', '', 'Finalizing', 'Complete'],
lss: ['Waiting for peers', 'Round 1: Commitments', 'Round 2: Share Distribution', 'Round 3: Verification', '', 'Finalizing', 'Complete'],
}
const statusToIndex: Record<string, number> = {
waiting: 0,
round1: 1,
round2: 2,
round3: 3,
round4: 4,
finalizing: 5,
complete: 6,
error: -1,
}
export function KeygenProgress({
status,
protocol,
participants,
readyCount = 0,
error,
}: KeygenProgressProps) {
const labels = roundLabels[protocol] ?? roundLabels.cggmp21
const currentIndex = statusToIndex[status] ?? 0
const totalSteps = labels.filter(Boolean).length
if (status === 'error') {
return (
<div className="rounded-lg border border-destructive/30 bg-destructive/10 p-6 text-center">
<div className="mx-auto mb-3 h-8 w-8 rounded-full border-2 border-destructive flex items-center justify-center text-destructive text-sm font-bold">
!
</div>
<p className="text-sm font-medium text-destructive">Key Generation Failed</p>
{error && <p className="mt-1 text-xs text-destructive/80">{error}</p>}
</div>
)
}
return (
<div className="rounded-lg border border-border bg-card p-6 space-y-4">
<div className="flex items-center justify-center gap-3">
{status !== 'complete' && (
<div className="h-5 w-5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
)}
<span className="text-sm font-medium">
{status === 'complete'
? 'Key Generation Complete'
: labels[currentIndex] || 'Processing...'}
</span>
</div>
{/* Progress bar */}
<div className="h-2 w-full rounded-full bg-muted">
<div
className="h-2 rounded-full bg-primary transition-all duration-500"
style={{ width: `${Math.max(5, (currentIndex / (totalSteps - 1)) * 100)}%` }}
/>
</div>
{/* Step indicators */}
<div className="flex justify-between text-xs text-muted-foreground">
{labels.filter(Boolean).map((label, i) => (
<span
key={label}
className={i <= currentIndex ? 'text-foreground' : ''}
>
{i + 1}
</span>
))}
</div>
{status === 'waiting' && (
<p className="text-center text-xs text-muted-foreground">
{readyCount} of {participants} participants ready
</p>
)}
</div>
)
}
@@ -0,0 +1,76 @@
'use client'
export interface ReshareWizardProps {
step: 'configuring' | 'confirming' | 'resharing' | 'complete' | 'error'
threshold: number
participants: string[]
error?: string
}
export function ReshareWizard({ step, threshold, participants, error }: ReshareWizardProps) {
const steps = ['Configure', 'Confirm', 'Reshare', 'Complete']
const stepIndex = { configuring: 0, confirming: 1, resharing: 2, complete: 3, error: 2 }
const current = stepIndex[step]
return (
<div className="space-y-6">
{/* Step indicators */}
<div className="flex items-center justify-between">
{steps.map((label, i) => (
<div key={label} className="flex items-center gap-2">
<div
className={`flex h-7 w-7 items-center justify-center rounded-full text-xs font-medium ${
i < current
? 'bg-primary text-primary-foreground'
: i === current
? 'border-2 border-primary text-primary'
: 'border border-border text-muted-foreground'
}`}
>
{i < current ? '\u2713' : i + 1}
</div>
<span
className={`hidden text-xs sm:inline ${
i <= current ? 'text-foreground' : 'text-muted-foreground'
}`}
>
{label}
</span>
{i < steps.length - 1 && (
<div
className={`mx-2 hidden h-px w-8 sm:block ${
i < current ? 'bg-primary' : 'bg-border'
}`}
/>
)}
</div>
))}
</div>
{/* Reshare progress */}
{step === 'resharing' && (
<div className="rounded-lg border border-border bg-card p-6 text-center space-y-4">
<div className="mx-auto h-5 w-5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<p className="text-sm font-medium">Resharing Key Shares</p>
<p className="text-xs text-muted-foreground">
Rotating to {threshold}-of-{participants.length} configuration.
All participants must remain online.
</p>
<div className="space-y-1 text-xs text-muted-foreground">
{participants.map((p) => (
<p key={p} className="font-mono">{p}</p>
))}
</div>
</div>
)}
{step === 'error' && (
<div className="rounded-lg border border-destructive/30 bg-destructive/10 p-6 text-center">
<p className="text-sm font-medium text-destructive">Reshare Failed</p>
{error && <p className="mt-1 text-xs text-destructive/80">{error}</p>}
</div>
)}
</div>
)
}
@@ -0,0 +1,64 @@
'use client'
import Link from 'next/link'
export interface WalletCardProps {
id: string
name: string
threshold: number
participants: number
ecdsaPubkey: string
eddsaPubkey?: string
protocol: 'cggmp21' | 'frost' | 'lss'
createdAt: string
}
export function WalletCard({
id,
name,
threshold,
participants,
ecdsaPubkey,
eddsaPubkey,
protocol,
createdAt,
}: WalletCardProps) {
const protocolLabel: Record<string, string> = {
cggmp21: 'CGGMP21',
frost: 'FROST',
lss: 'LSS',
}
return (
<Link
href={`/wallets/${id}`}
className="block rounded-lg border border-border bg-card p-6 transition-colors hover:border-muted-foreground/30 hover:bg-accent/50"
>
<div className="flex items-start justify-between">
<h3 className="text-sm font-semibold">{name}</h3>
<span className="rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground">
{protocolLabel[protocol] ?? protocol}
</span>
</div>
<p className="mt-2 font-mono text-xs text-muted-foreground">
{threshold}-of-{participants}
</p>
<div className="mt-3 space-y-1">
<p className="truncate font-mono text-xs text-muted-foreground" title={ecdsaPubkey}>
ECDSA: {ecdsaPubkey}
</p>
{eddsaPubkey && (
<p className="truncate font-mono text-xs text-muted-foreground" title={eddsaPubkey}>
EdDSA: {eddsaPubkey}
</p>
)}
</div>
<p className="mt-3 text-xs text-muted-foreground">
Created {new Date(createdAt).toLocaleDateString()}
</p>
</Link>
)
}
+438
View File
@@ -0,0 +1,438 @@
// API client for Lux MPC backend (all routes from pkg/api/server.go)
import { getToken, getRefreshToken, setTokens, clearTokens } from './auth'
import type {
AuthResponse,
RegisterRequest,
LoginRequest,
MFARequiredResponse,
Vault,
CreateVaultRequest,
UpdateVaultRequest,
Wallet,
WalletAddresses,
CreateWalletRequest,
ReshareWalletRequest,
Transaction,
TransactionFilters,
CreateTransactionRequest,
RejectTransactionRequest,
Policy,
CreatePolicyRequest,
UpdatePolicyRequest,
AddressWhitelist,
AddWhitelistRequest,
Webhook,
CreateWebhookRequest,
UpdateWebhookRequest,
Subscription,
CreateSubscriptionRequest,
UpdateSubscriptionRequest,
PaymentRequest,
CreatePaymentRequestRequest,
PayPaymentRequestRequest,
SmartWallet,
DeploySmartWalletRequest,
ProposeSafeTxRequest,
ExecuteSafeTxRequest,
UserOperationRequest,
User,
InviteUserRequest,
UpdateUserRequest,
APIKey,
APIKeyCreateResponse,
CreateAPIKeyRequest,
AuditEntry,
ClusterStatus,
InfoResponse,
} from './types'
export class APIError extends Error {
constructor(
public status: number,
public body: { error: string },
) {
super(body.error)
this.name = 'APIError'
}
}
export class APIClient {
private baseURL: string
private refreshing: Promise<void> | null = null
constructor(baseURL?: string) {
this.baseURL = baseURL ?? (process.env.NEXT_PUBLIC_API_URL || '/api/v1')
}
private async request<T>(
method: string,
path: string,
body?: unknown,
retry = true,
): Promise<T> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
const token = getToken()
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
const res = await fetch(`${this.baseURL}${path}`, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
})
// Handle 401 with automatic token refresh
if (res.status === 401 && retry) {
await this.tryRefresh()
return this.request<T>(method, path, body, false)
}
if (res.status === 204) {
return undefined as T
}
const data = await res.json()
if (!res.ok) {
throw new APIError(res.status, data)
}
return data as T
}
private async tryRefresh(): Promise<void> {
// Deduplicate concurrent refresh attempts
if (this.refreshing) {
await this.refreshing
return
}
this.refreshing = (async () => {
const refreshToken = getRefreshToken()
if (!refreshToken) {
clearTokens()
throw new APIError(401, { error: 'no refresh token' })
}
try {
const res = await fetch(`${this.baseURL}/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_token: refreshToken }),
})
if (!res.ok) {
clearTokens()
throw new APIError(401, { error: 'refresh failed' })
}
const data: AuthResponse = await res.json()
setTokens(data.access_token, data.refresh_token)
} catch (err) {
clearTokens()
throw err
}
})()
try {
await this.refreshing
} finally {
this.refreshing = null
}
}
private get<T>(path: string): Promise<T> {
return this.request<T>('GET', path)
}
private post<T>(path: string, body?: unknown): Promise<T> {
return this.request<T>('POST', path, body)
}
private patch<T>(path: string, body?: unknown): Promise<T> {
return this.request<T>('PATCH', path, body)
}
private del<T>(path: string): Promise<T> {
return this.request<T>('DELETE', path)
}
// --- Auth ---
async register(req: RegisterRequest): Promise<AuthResponse> {
return this.post<AuthResponse>('/auth/register', req)
}
async login(req: LoginRequest): Promise<AuthResponse | MFARequiredResponse> {
return this.post<AuthResponse | MFARequiredResponse>('/auth/login', req)
}
async refresh(refreshToken: string): Promise<AuthResponse> {
return this.post<AuthResponse>('/auth/refresh', { refresh_token: refreshToken })
}
async mfaSetup(): Promise<{ secret: string; otpauth: string }> {
return this.post('/auth/mfa/setup')
}
async mfaVerify(code: string): Promise<{ status: string }> {
return this.post('/auth/mfa/verify', { code })
}
// --- Vaults ---
async listVaults(): Promise<Vault[]> {
return this.get<Vault[]>('/vaults')
}
async createVault(req: CreateVaultRequest): Promise<Vault> {
return this.post<Vault>('/vaults', req)
}
async getVault(id: string): Promise<Vault> {
return this.get<Vault>(`/vaults/${id}`)
}
async updateVault(id: string, req: UpdateVaultRequest): Promise<Vault> {
return this.patch<Vault>(`/vaults/${id}`, req)
}
async deleteVault(id: string): Promise<void> {
return this.del(`/vaults/${id}`)
}
// --- Wallets ---
async listWallets(vaultId: string): Promise<Wallet[]> {
return this.get<Wallet[]>(`/vaults/${vaultId}/wallets`)
}
async createWallet(vaultId: string, req: CreateWalletRequest): Promise<Wallet> {
return this.post<Wallet>(`/vaults/${vaultId}/wallets`, req)
}
async getWallet(id: string): Promise<Wallet> {
return this.get<Wallet>(`/wallets/${id}`)
}
async getWalletAddresses(id: string): Promise<WalletAddresses> {
return this.get<WalletAddresses>(`/wallets/${id}/addresses`)
}
async reshareWallet(id: string, req: ReshareWalletRequest): Promise<{ status: string }> {
return this.post(`/wallets/${id}/reshare`, req)
}
async getWalletHistory(id: string): Promise<Transaction[]> {
return this.get<Transaction[]>(`/wallets/${id}/history`)
}
// --- Transactions ---
async createTransaction(req: CreateTransactionRequest): Promise<Transaction> {
return this.post<Transaction>('/transactions', req)
}
async listTransactions(filters?: TransactionFilters): Promise<Transaction[]> {
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 this.get<Transaction[]>(`/transactions${qs ? `?${qs}` : ''}`)
}
async getTransaction(id: string): Promise<Transaction> {
return this.get<Transaction>(`/transactions/${id}`)
}
async approveTransaction(id: string): Promise<{ status: string }> {
return this.post(`/transactions/${id}/approve`)
}
async rejectTransaction(id: string, req?: RejectTransactionRequest): Promise<{ status: string }> {
return this.post(`/transactions/${id}/reject`, req)
}
// --- Policies ---
async listPolicies(): Promise<Policy[]> {
return this.get<Policy[]>('/policies')
}
async createPolicy(req: CreatePolicyRequest): Promise<Policy> {
return this.post<Policy>('/policies', req)
}
async updatePolicy(id: string, req: UpdatePolicyRequest): Promise<Policy> {
return this.patch<Policy>(`/policies/${id}`, req)
}
async deletePolicy(id: string): Promise<void> {
return this.del(`/policies/${id}`)
}
// --- Whitelist ---
async listWhitelist(): Promise<AddressWhitelist[]> {
return this.get<AddressWhitelist[]>('/whitelist')
}
async addWhitelist(req: AddWhitelistRequest): Promise<AddressWhitelist> {
return this.post<AddressWhitelist>('/whitelist', req)
}
async deleteWhitelist(id: string): Promise<void> {
return this.del(`/whitelist/${id}`)
}
// --- Webhooks ---
async listWebhooks(): Promise<Webhook[]> {
return this.get<Webhook[]>('/webhooks')
}
async createWebhook(req: CreateWebhookRequest): Promise<Webhook> {
return this.post<Webhook>('/webhooks', req)
}
async updateWebhook(id: string, req: UpdateWebhookRequest): Promise<Webhook> {
return this.patch<Webhook>(`/webhooks/${id}`, req)
}
async deleteWebhook(id: string): Promise<void> {
return this.del(`/webhooks/${id}`)
}
async testWebhook(id: string): Promise<{ status: string }> {
return this.post(`/webhooks/${id}/test`)
}
// --- Subscriptions ---
async listSubscriptions(): Promise<Subscription[]> {
return this.get<Subscription[]>('/subscriptions')
}
async createSubscription(req: CreateSubscriptionRequest): Promise<Subscription> {
return this.post<Subscription>('/subscriptions', req)
}
async getSubscription(id: string): Promise<Subscription> {
return this.get<Subscription>(`/subscriptions/${id}`)
}
async updateSubscription(id: string, req: UpdateSubscriptionRequest): Promise<Subscription> {
return this.patch<Subscription>(`/subscriptions/${id}`, req)
}
async deleteSubscription(id: string): Promise<void> {
return this.del(`/subscriptions/${id}`)
}
async payNow(id: string): Promise<{ status: string; tx_id: string }> {
return this.post(`/subscriptions/${id}/pay-now`)
}
// --- Payment Requests ---
async createPaymentRequest(req: CreatePaymentRequestRequest): Promise<{
payment_request: PaymentRequest
payment_url: string
}> {
return this.post('/payment-requests', req)
}
async listPaymentRequests(): Promise<PaymentRequest[]> {
return this.get<PaymentRequest[]>('/payment-requests')
}
async getPaymentRequest(id: string): Promise<PaymentRequest> {
return this.get<PaymentRequest>(`/payment-requests/${id}`)
}
async payPaymentRequest(id: string, req: PayPaymentRequestRequest): Promise<{ status: string; tx_id: string }> {
return this.post(`/payment-requests/${id}/pay`, req)
}
// --- Smart Wallets ---
async deploySmartWallet(walletId: string, req: DeploySmartWalletRequest): Promise<SmartWallet> {
return this.post<SmartWallet>(`/wallets/${walletId}/smart-wallet`, req)
}
async listSmartWallets(walletId: string): Promise<SmartWallet[]> {
return this.get<SmartWallet[]>(`/wallets/${walletId}/smart-wallets`)
}
async getSmartWallet(id: string): Promise<SmartWallet> {
return this.get<SmartWallet>(`/smart-wallets/${id}`)
}
async proposeSafeTx(id: string, req: ProposeSafeTxRequest): Promise<{ status: string; smart_wallet_id: string; to: string; value: string }> {
return this.post(`/smart-wallets/${id}/propose`, req)
}
async executeSafeTx(id: string, req: ExecuteSafeTxRequest): Promise<{ status: string; safe_tx_hash: string }> {
return this.post(`/smart-wallets/${id}/execute`, req)
}
async userOperation(id: string, req: UserOperationRequest): Promise<{ status: string; smart_wallet_id: string }> {
return this.post(`/smart-wallets/${id}/user-operation`, req)
}
// --- Users & Teams ---
async listUsers(): Promise<User[]> {
return this.get<User[]>('/users')
}
async inviteUser(req: InviteUserRequest): Promise<User> {
return this.post<User>('/users', req)
}
async updateUser(id: string, req: UpdateUserRequest): Promise<User> {
return this.patch<User>(`/users/${id}`, req)
}
async deleteUser(id: string): Promise<void> {
return this.del(`/users/${id}`)
}
// --- API Keys ---
async listAPIKeys(): Promise<APIKey[]> {
return this.get<APIKey[]>('/api-keys')
}
async createAPIKey(req: CreateAPIKeyRequest): Promise<APIKeyCreateResponse> {
return this.post<APIKeyCreateResponse>('/api-keys', req)
}
async deleteAPIKey(id: string): Promise<void> {
return this.del(`/api-keys/${id}`)
}
// --- Audit ---
async listAudit(): Promise<AuditEntry[]> {
return this.get<AuditEntry[]>('/audit')
}
// --- Status & Info ---
async getStatus(): Promise<ClusterStatus> {
return this.get<ClusterStatus>('/status')
}
async getInfo(): Promise<InfoResponse> {
return this.get<InfoResponse>('/info')
}
}
// Singleton instance
export const api = new APIClient()
+30
View File
@@ -0,0 +1,30 @@
// JWT token management via localStorage
const ACCESS_TOKEN_KEY = 'lux_mpc_access_token'
const REFRESH_TOKEN_KEY = 'lux_mpc_refresh_token'
export function getToken(): string | null {
if (typeof window === 'undefined') return null
return localStorage.getItem(ACCESS_TOKEN_KEY)
}
export function getRefreshToken(): string | null {
if (typeof window === 'undefined') return null
return localStorage.getItem(REFRESH_TOKEN_KEY)
}
export function setTokens(accessToken: string, refreshToken: string): void {
if (typeof window === 'undefined') return
localStorage.setItem(ACCESS_TOKEN_KEY, accessToken)
localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken)
}
export function clearTokens(): void {
if (typeof window === 'undefined') return
localStorage.removeItem(ACCESS_TOKEN_KEY)
localStorage.removeItem(REFRESH_TOKEN_KEY)
}
export function isAuthenticated(): boolean {
return getToken() !== null
}
+38
View File
@@ -0,0 +1,38 @@
'use client'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { api } from '@/lib/api'
import { setTokens, clearTokens } from '@/lib/auth'
import type { LoginRequest, RegisterRequest } from '@/lib/types'
export function useLogin() {
return useMutation({
mutationFn: (req: LoginRequest) => api.login(req),
onSuccess: (data) => {
if ('access_token' in data) {
setTokens(data.access_token, data.refresh_token)
}
},
})
}
export function useRegister() {
return useMutation({
mutationFn: (req: RegisterRequest) => api.register(req),
onSuccess: (data) => {
setTokens(data.access_token, data.refresh_token)
},
})
}
export function useLogout() {
const qc = useQueryClient()
return useMutation({
mutationFn: async () => {
clearTokens()
},
onSuccess: () => {
qc.invalidateQueries()
},
})
}
+45
View File
@@ -0,0 +1,45 @@
'use client'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { api } from '@/lib/api'
import type { CreatePolicyRequest, UpdatePolicyRequest } from '@/lib/types'
const POLICIES_KEY = ['policies'] as const
export function usePolicies() {
return useQuery({
queryKey: POLICIES_KEY,
queryFn: () => api.listPolicies(),
})
}
export function useCreatePolicy() {
const qc = useQueryClient()
return useMutation({
mutationFn: (req: CreatePolicyRequest) => api.createPolicy(req),
onSuccess: () => {
qc.invalidateQueries({ queryKey: POLICIES_KEY })
},
})
}
export function useUpdatePolicy() {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ id, ...req }: UpdatePolicyRequest & { id: string }) =>
api.updatePolicy(id, req),
onSuccess: () => {
qc.invalidateQueries({ queryKey: POLICIES_KEY })
},
})
}
export function useDeletePolicy() {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) => api.deletePolicy(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: POLICIES_KEY })
},
})
}
+55
View File
@@ -0,0 +1,55 @@
'use client'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { api } from '@/lib/api'
import type { TransactionFilters, CreateTransactionRequest, RejectTransactionRequest } from '@/lib/types'
const TX_KEY = ['transactions'] as const
export function useTransactions(filters?: TransactionFilters) {
return useQuery({
queryKey: [...TX_KEY, filters ?? {}],
queryFn: () => api.listTransactions(filters),
})
}
export function useTransaction(id: string) {
return useQuery({
queryKey: [...TX_KEY, id],
queryFn: () => api.getTransaction(id),
enabled: !!id,
})
}
export function useCreateTransaction() {
const qc = useQueryClient()
return useMutation({
mutationFn: (req: CreateTransactionRequest) => api.createTransaction(req),
onSuccess: () => {
qc.invalidateQueries({ queryKey: TX_KEY })
},
})
}
export function useApproveTransaction() {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) => api.approveTransaction(id),
onSuccess: (_data, id) => {
qc.invalidateQueries({ queryKey: TX_KEY })
qc.invalidateQueries({ queryKey: [...TX_KEY, id] })
},
})
}
export function useRejectTransaction() {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ id, ...req }: RejectTransactionRequest & { id: string }) =>
api.rejectTransaction(id, req),
onSuccess: (_data, variables) => {
qc.invalidateQueries({ queryKey: TX_KEY })
qc.invalidateQueries({ queryKey: [...TX_KEY, variables.id] })
},
})
}
+54
View File
@@ -0,0 +1,54 @@
'use client'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { api } from '@/lib/api'
import type { CreateVaultRequest, UpdateVaultRequest } from '@/lib/types'
const VAULTS_KEY = ['vaults'] as const
export function useVaults() {
return useQuery({
queryKey: VAULTS_KEY,
queryFn: () => api.listVaults(),
})
}
export function useVault(id: string) {
return useQuery({
queryKey: [...VAULTS_KEY, id],
queryFn: () => api.getVault(id),
enabled: !!id,
})
}
export function useCreateVault() {
const qc = useQueryClient()
return useMutation({
mutationFn: (req: CreateVaultRequest) => api.createVault(req),
onSuccess: () => {
qc.invalidateQueries({ queryKey: VAULTS_KEY })
},
})
}
export function useUpdateVault() {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ id, ...req }: UpdateVaultRequest & { id: string }) =>
api.updateVault(id, req),
onSuccess: (_data, variables) => {
qc.invalidateQueries({ queryKey: VAULTS_KEY })
qc.invalidateQueries({ queryKey: [...VAULTS_KEY, variables.id] })
},
})
}
export function useDeleteVault() {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) => api.deleteVault(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: VAULTS_KEY })
},
})
}
+61
View File
@@ -0,0 +1,61 @@
'use client'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { api } from '@/lib/api'
import type { CreateWalletRequest, ReshareWalletRequest } from '@/lib/types'
const WALLETS_KEY = ['wallets'] as const
export function useWallets(vaultId: string) {
return useQuery({
queryKey: [...WALLETS_KEY, 'by-vault', vaultId],
queryFn: () => api.listWallets(vaultId),
enabled: !!vaultId,
})
}
export function useWallet(id: string) {
return useQuery({
queryKey: [...WALLETS_KEY, id],
queryFn: () => api.getWallet(id),
enabled: !!id,
})
}
export function useWalletAddresses(id: string) {
return useQuery({
queryKey: [...WALLETS_KEY, id, 'addresses'],
queryFn: () => api.getWalletAddresses(id),
enabled: !!id,
})
}
export function useWalletHistory(id: string) {
return useQuery({
queryKey: [...WALLETS_KEY, id, 'history'],
queryFn: () => api.getWalletHistory(id),
enabled: !!id,
})
}
export function useCreateWallet() {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ vaultId, ...req }: CreateWalletRequest & { vaultId: string }) =>
api.createWallet(vaultId, req),
onSuccess: (_data, variables) => {
qc.invalidateQueries({ queryKey: [...WALLETS_KEY, 'by-vault', variables.vaultId] })
},
})
}
export function useReshareWallet() {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ id, ...req }: ReshareWalletRequest & { id: string }) =>
api.reshareWallet(id, req),
onSuccess: (_data, variables) => {
qc.invalidateQueries({ queryKey: [...WALLETS_KEY, variables.id] })
},
})
}
+416
View File
@@ -0,0 +1,416 @@
// Types matching Go backend models (pkg/db/models.go + pkg/api/server.go)
// --- Core entities ---
export interface Organization {
id: string
name: string
slug: string
created_at: string
}
export interface User {
id: string
org_id: string
email: string
role: string // owner | admin | signer | viewer
created_at: string
}
export interface APIKey {
id: string
org_id: string
name: string
key_prefix: string
permissions: string[]
created_at: string
expires_at?: string | null
last_used_at?: string | null
}
export interface APIKeyCreateResponse extends APIKey {
key: string // full key, returned only on creation
}
export interface Vault {
id: string
org_id: string
name: string
description?: string | null
app_id?: string | null
created_at: string
}
export interface Wallet {
id: string
vault_id: string
org_id: string
wallet_id: string
name?: string | null
key_type: string // secp256k1 | ed25519
ecdsa_pubkey?: string | null
eddsa_pubkey?: string | null
eth_address?: string | null
btc_address?: string | null
sol_address?: string | null
threshold: number
participants: string[]
version: number
status: string // active | resharing | archived
created_by?: string | null
created_at: string
}
export interface WalletAddresses {
ethereum?: string
bitcoin?: string
solana?: string
}
export interface Transaction {
id: string
org_id: string
wallet_id?: string | null
tx_type: string
chain: string
to_address?: string | null
amount?: string | null
token?: string | null
tx_hash?: string | null
signature_r?: string | null
signature_s?: string | null
signature_eddsa?: string | null
status: string // pending | pending_approval | approved | signing | signed | broadcast | failed | rejected
initiated_by?: string | null
approved_by?: string[]
rejected_by?: string | null
rejection_reason?: string | null
created_at: string
signed_at?: string | null
broadcast_at?: string | null
}
export interface Policy {
id: string
org_id: string
vault_id?: string | null
name: string
priority: number
action: string // approve | deny | require_approval
conditions: Record<string, unknown>
required_approvers: number
approver_roles: string[]
enabled: boolean
created_at: string
}
export interface AddressWhitelist {
id: string
org_id: string
vault_id?: string | null
address: string
chain: string
label?: string | null
created_by?: string | null
created_at: string
}
export interface AuditEntry {
id: number
org_id: string
user_id?: string | null
action: string
resource_type?: string | null
resource_id?: string | null
details?: Record<string, unknown> | null
ip_address?: string | null
created_at: string
}
export interface Webhook {
id: string
org_id: string
url: string
events: string[]
enabled: boolean
created_at: string
}
export interface Subscription {
id: string
org_id: string
wallet_id?: string | null
name: string
provider_name?: string | null
recipient_address: string
chain: string
token?: string | null
amount: string
currency: string
interval: string // daily | weekly | monthly | yearly
next_payment_at: string
last_payment_at?: string | null
last_tx_id?: string | null
status: string // active | paused | cancelled | failed
max_retries: number
retry_count: number
require_balance: boolean
created_by?: string | null
cancelled_by?: string | null
created_at: string
cancelled_at?: string | null
}
export interface PaymentRequest {
id: string
org_id: string
wallet_id?: string | null
request_token: string
merchant_name?: string | null
recipient_address: string
chain: string
token?: string | null
amount: string
memo?: string | null
status: string // pending | paid | expired | cancelled
expires_at?: string | null
paid_tx_id?: string | null
created_at: string
}
export interface SmartWallet {
id: string
wallet_id: string
org_id: string
chain: string
contract_address: string
wallet_type: string // safe | erc4337
factory_address?: string | null
entrypoint_address?: string | null
salt?: string | null
owners: string[]
threshold: number
status: string // active | deploying | archived
deployed_at?: string | null
created_at: string
}
// --- MPC cluster types (pkg/api/server.go) ---
export interface ClusterStatus {
node_id: string
mode: string
expected_peers: number
connected_peers: number
ready: boolean
threshold: number
version: string
}
export interface KeygenResult {
wallet_id: string
ecdsa_pub_key: string
eddsa_pub_key: string
eth_address: string
}
export interface SignResult {
r?: string
s?: string
signature?: string
}
export interface InfoResponse {
name: string
version: string
supported_chains: string[]
key_types: string[]
protocols: string[]
}
// --- Auth request/response types ---
export interface RegisterRequest {
org_name: string
email: string
password: string
}
export interface LoginRequest {
email: string
password: string
mfa_code?: string
}
export interface RefreshRequest {
refresh_token: string
}
export interface AuthResponse {
user_id: string
org_id: string
role?: string
access_token: string
refresh_token: string
}
export interface MFARequiredResponse {
mfa_required: true
}
export interface ErrorResponse {
error: string
}
// --- Request body types for create/update ---
export interface CreateVaultRequest {
name: string
description?: string
app_id?: string
}
export interface UpdateVaultRequest {
name?: string
description?: string
}
export interface CreateWalletRequest {
name?: string
key_type?: string // secp256k1 | ed25519
}
export interface ReshareWalletRequest {
new_threshold: number
new_participants: string[]
}
export interface CreateTransactionRequest {
wallet_id: string
tx_type: string
chain: string
to_address: string
amount: string
token?: string
raw_tx?: string
}
export interface RejectTransactionRequest {
reason?: string
}
export interface CreatePolicyRequest {
vault_id?: string
name: string
priority: number
action: string
conditions: Record<string, unknown>
required_approvers: number
approver_roles: string[]
}
export interface UpdatePolicyRequest {
name?: string
priority?: number
action?: string
conditions?: Record<string, unknown>
required_approvers?: number
enabled?: boolean
}
export interface AddWhitelistRequest {
vault_id?: string
address: string
chain: string
label?: string
}
export interface CreateWebhookRequest {
url: string
events: string[]
secret: string
}
export interface UpdateWebhookRequest {
url?: string
events?: string[]
enabled?: boolean
}
export interface CreateSubscriptionRequest {
wallet_id: string
name: string
provider_name?: string
recipient_address: string
chain: string
token?: string
amount: string
currency?: string
interval: string
require_balance?: boolean
}
export interface UpdateSubscriptionRequest {
status?: string
amount?: string
}
export interface CreatePaymentRequestRequest {
wallet_id?: string
merchant_name?: string
recipient_address: string
chain: string
token?: string
amount: string
memo?: string
expires_in_hours?: number
}
export interface PayPaymentRequestRequest {
wallet_id: string
}
export interface DeploySmartWalletRequest {
chain: string
wallet_type: string // safe | erc4337
factory_address?: string
entrypoint_address?: string
salt?: string
owners: string[]
threshold: number
}
export interface ProposeSafeTxRequest {
to: string
value: string
data?: string
}
export interface ExecuteSafeTxRequest {
safe_tx_hash: string
}
export interface UserOperationRequest {
call_data: string
value?: string
}
export interface InviteUserRequest {
email: string
role?: string
password: string
}
export interface UpdateUserRequest {
role?: string
}
export interface CreateAPIKeyRequest {
name: string
permissions?: string[]
}
export interface TransactionFilters {
status?: string
chain?: string
}
+5
View File
@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
+23
View File
@@ -0,0 +1,23 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
experimental: {
typedRoutes: false,
},
eslint: {
ignoreDuringBuilds: false,
},
typescript: {
ignoreBuildErrors: false,
},
async rewrites() {
return [
{
source: '/api/v1/:path*',
destination: 'http://localhost:8081/api/v1/:path*',
},
]
},
}
module.exports = nextConfig
+6224
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
{
"name": "@luxfi/mpc-dashboard",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"next": "^14.2.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"@tanstack/react-query": "^5.60.0",
"zustand": "^5.0.0",
"geist": "^1.3.0"
},
"devDependencies": {
"typescript": "^5.6.0",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@types/node": "^22.0.0",
"tailwindcss": "^3.4.0",
"postcss": "^8.4.0",
"autoprefixer": "^10.4.0",
"eslint": "^8.57.0",
"eslint-config-next": "^14.2.0"
}
}
+9
View File
@@ -0,0 +1,9 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
module.exports = config
+57
View File
@@ -0,0 +1,57 @@
import type { Config } from 'tailwindcss'
const config: Config = {
darkMode: 'class',
content: [
'./app/**/*.{ts,tsx}',
'./components/**/*.{ts,tsx}',
'./lib/**/*.{ts,tsx}',
],
theme: {
extend: {
fontFamily: {
sans: ['var(--font-geist-sans)'],
mono: ['var(--font-geist-mono)'],
},
colors: {
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
},
},
plugins: [],
}
export default config
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
+6
View File
@@ -42,10 +42,13 @@ require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-chi/chi/v5 v5.2.5 // indirect
github.com/go-chi/cors v1.2.2 // indirect
github.com/go-ini/ini v1.67.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/google/flatbuffers v25.12.19+incompatible // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
@@ -58,6 +61,9 @@ require (
github.com/hashicorp/go-version v1.7.0 // indirect
github.com/hashicorp/golang-lru v1.0.2 // indirect
github.com/hashicorp/serf v0.10.2 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.8.0 // indirect
github.com/klauspost/compress v1.18.3 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/klauspost/crc32 v1.3.0 // indirect
+14
View File
@@ -62,6 +62,10 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
@@ -82,6 +86,8 @@ github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPE
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
@@ -147,6 +153,12 @@ github.com/hashicorp/memberlist v0.5.2 h1:rJoNPWZ0juJBgqn48gjy59K5H4rNgvUoM1kUD7
github.com/hashicorp/memberlist v0.5.2/go.mod h1:Ri9p/tRShbjYnpNf4FFPXG7wxEGY4Nrcn6E7jrVa//4=
github.com/hashicorp/serf v0.10.2 h1:m5IORhuNSjaxeljg5DeQVDlQyVkhRIjJDimbkCa8aAc=
github.com/hashicorp/serf v0.10.2/go.mod h1:T1CmSGfSeGfnfNy/w0odXQUR1rfECGd2Qdsp84DjOiY=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
@@ -293,6 +305,7 @@ github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
@@ -411,5 +424,6 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+73
View File
@@ -0,0 +1,73 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: mpc-api
namespace: lux-mpc
labels:
app: mpc-api
app.kubernetes.io/part-of: lux-mpc
spec:
replicas: 1
selector:
matchLabels:
app: mpc-api
template:
metadata:
labels:
app: mpc-api
app.kubernetes.io/part-of: lux-mpc
spec:
containers:
- name: mpc-api
image: ghcr.io/luxfi/mpc-api:latest
args:
- start
- --mode
- consensus
- --api-db
- $(DATABASE_URL)
- --api-listen
- ":8081"
- --jwt-secret
- $(JWT_SECRET)
ports:
- containerPort: 8081
name: api
protocol: TCP
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: mpc-secrets
key: DATABASE_URL
- name: JWT_SECRET
valueFrom:
secretKeyRef:
name: mpc-secrets
key: jwt-secret
# NOTE: /api/v1/status requires auth and returns 401 for unauthenticated
# requests. If the probe fails, add a public /healthz endpoint to the API
# or switch to tcpSocket probe.
livenessProbe:
httpGet:
path: /api/v1/status
port: 8081
initialDelaySeconds: 15
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /api/v1/status
port: 8081
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 500m
memory: 512Mi
+17
View File
@@ -0,0 +1,17 @@
apiVersion: v1
kind: Service
metadata:
name: mpc-api-svc
namespace: lux-mpc
labels:
app: mpc-api
app.kubernetes.io/part-of: lux-mpc
spec:
type: ClusterIP
ports:
- port: 8081
targetPort: 8081
protocol: TCP
name: api
selector:
app: mpc-api
+15
View File
@@ -0,0 +1,15 @@
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: mpc-lux-network-tls
namespace: lux-mpc
labels:
app.kubernetes.io/part-of: lux-mpc
spec:
secretName: mpc-lux-network-tls
dnsNames:
- mpc.lux.network
- cloud.lux.network
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
+52
View File
@@ -0,0 +1,52 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: mpc-dashboard
namespace: lux-mpc
labels:
app: mpc-dashboard
app.kubernetes.io/part-of: lux-mpc
spec:
replicas: 1
selector:
matchLabels:
app: mpc-dashboard
template:
metadata:
labels:
app: mpc-dashboard
app.kubernetes.io/part-of: lux-mpc
spec:
containers:
- name: dashboard
image: ghcr.io/luxfi/mpc-dashboard:latest
ports:
- containerPort: 3000
name: http
protocol: TCP
env:
- name: NEXT_PUBLIC_API_URL
value: "https://mpc.lux.network/api/v1"
livenessProbe:
httpGet:
path: /
port: 3000
initialDelaySeconds: 10
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /
port: 3000
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
cpu: 200m
memory: 256Mi
+17
View File
@@ -0,0 +1,17 @@
apiVersion: v1
kind: Service
metadata:
name: mpc-dashboard-svc
namespace: lux-mpc
labels:
app: mpc-dashboard
app.kubernetes.io/part-of: lux-mpc
spec:
type: ClusterIP
ports:
- port: 3000
targetPort: 3000
protocol: TCP
name: http
selector:
app: mpc-dashboard
+27
View File
@@ -0,0 +1,27 @@
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: mpc-cloud
namespace: lux-mpc
labels:
app.kubernetes.io/part-of: lux-mpc
spec:
entryPoints:
- websecure
routes:
# API routes - both domains
- match: (Host(`mpc.lux.network`) || Host(`cloud.lux.network`)) && PathPrefix(`/api/v1`)
kind: Rule
services:
- name: mpc-api-svc
port: 8081
passHostHeader: false
# Dashboard - catch-all for both domains
- match: Host(`mpc.lux.network`) || Host(`cloud.lux.network`)
kind: Rule
services:
- name: mpc-dashboard-svc
port: 3000
passHostHeader: false
tls:
secretName: mpc-lux-network-tls
+6
View File
@@ -0,0 +1,6 @@
apiVersion: v1
kind: Namespace
metadata:
name: lux-mpc
labels:
app.kubernetes.io/part-of: lux-mpc
+101
View File
@@ -0,0 +1,101 @@
---
apiVersion: v1
kind: Service
metadata:
name: mpc-postgres
namespace: lux-mpc
labels:
app: mpc-postgres
app.kubernetes.io/part-of: lux-mpc
spec:
type: ClusterIP
ports:
- port: 5432
targetPort: 5432
protocol: TCP
name: postgres
selector:
app: mpc-postgres
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: mpc-postgres
namespace: lux-mpc
labels:
app: mpc-postgres
app.kubernetes.io/part-of: lux-mpc
spec:
serviceName: mpc-postgres
replicas: 1
selector:
matchLabels:
app: mpc-postgres
template:
metadata:
labels:
app: mpc-postgres
app.kubernetes.io/part-of: lux-mpc
spec:
containers:
- name: postgres
image: postgres:16-alpine
ports:
- containerPort: 5432
name: postgres
env:
- name: POSTGRES_USER
value: mpc
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: mpc-secrets
key: postgres-password
- name: POSTGRES_DB
value: mpc_api
- name: PGDATA
value: /var/lib/postgresql/data/pgdata
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 500m
memory: 512Mi
readinessProbe:
exec:
command:
- pg_isready
- -U
- mpc
- -d
- mpc_api
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 5
livenessProbe:
exec:
command:
- pg_isready
- -U
- mpc
- -d
- mpc_api
initialDelaySeconds: 15
periodSeconds: 20
timeoutSeconds: 5
volumeMounts:
- name: postgres-data
mountPath: /var/lib/postgresql/data
volumeClaimTemplates:
- metadata:
name: postgres-data
labels:
app: mpc-postgres
app.kubernetes.io/part-of: lux-mpc
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 10Gi
+15
View File
@@ -0,0 +1,15 @@
apiVersion: v1
kind: Secret
metadata:
name: mpc-secrets
namespace: lux-mpc
labels:
app.kubernetes.io/part-of: lux-mpc
type: Opaque
stringData:
# TODO: Replace with a strong random password (e.g. openssl rand -base64 32)
postgres-password: "CHANGE-ME-postgres-password"
# TODO: Replace with a strong random JWT secret (e.g. openssl rand -base64 64)
jwt-secret: "CHANGE-ME-jwt-secret"
# Constructed from the above password - update if postgres-password changes
DATABASE_URL: "postgres://mpc:[email protected]:5432/mpc_api?sslmode=disable"
+106
View File
@@ -0,0 +1,106 @@
package api
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/bcrypt"
)
type JWTClaims struct {
UserID string `json:"user_id"`
OrgID string `json:"org_id"`
Role string `json:"role"`
jwt.RegisteredClaims
}
func (s *Server) generateJWT(userID, orgID, role string) (string, error) {
claims := JWTClaims{
UserID: userID,
OrgID: orgID,
Role: role,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(s.jwtSecret)
}
func (s *Server) generateRefreshToken(userID, orgID, role string) (string, error) {
claims := JWTClaims{
UserID: userID,
OrgID: orgID,
Role: role,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(7 * 24 * time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(s.jwtSecret)
}
func (s *Server) validateJWT(tokenStr string) (*JWTClaims, error) {
token, err := jwt.ParseWithClaims(tokenStr, &JWTClaims{}, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return s.jwtSecret, nil
})
if err != nil {
return nil, err
}
if claims, ok := token.Claims.(*JWTClaims); ok && token.Valid {
return claims, nil
}
return nil, fmt.Errorf("invalid token")
}
func hashPassword(password string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(hash), nil
}
func checkPassword(hash, password string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
}
func generateAPIKeyToken() (string, string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", "", err
}
key := "lux_" + hex.EncodeToString(b)
hash := sha256.Sum256([]byte(key))
return key, hex.EncodeToString(hash[:]), nil
}
func (s *Server) validateAPIKey(ctx context.Context, key string) (string, error) {
hash := sha256.Sum256([]byte(key))
keyHash := hex.EncodeToString(hash[:])
prefix := key
if len(prefix) > 8 {
prefix = prefix[:8]
}
var orgID string
err := s.db.Pool.QueryRow(ctx,
`UPDATE api_keys SET last_used_at = NOW()
WHERE key_hash = $1 AND key_prefix = $2
AND (expires_at IS NULL OR expires_at > NOW())
RETURNING org_id`, keyHash, prefix).Scan(&orgID)
if err != nil {
return "", fmt.Errorf("invalid api key")
}
return orgID, nil
}
+169
View File
@@ -0,0 +1,169 @@
package api
import (
"strings"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
)
func newTestServer(secret string) *Server {
return &Server{
jwtSecret: []byte(secret),
}
}
func TestGenerateAndValidateJWT(t *testing.T) {
s := newTestServer("test-secret-key-for-jwt")
token, err := s.generateJWT("user-123", "org-456", "admin")
if err != nil {
t.Fatalf("generateJWT: %v", err)
}
if token == "" {
t.Fatal("generateJWT returned empty token")
}
claims, err := s.validateJWT(token)
if err != nil {
t.Fatalf("validateJWT: %v", err)
}
if claims.UserID != "user-123" {
t.Errorf("UserID = %q, want %q", claims.UserID, "user-123")
}
if claims.OrgID != "org-456" {
t.Errorf("OrgID = %q, want %q", claims.OrgID, "org-456")
}
if claims.Role != "admin" {
t.Errorf("Role = %q, want %q", claims.Role, "admin")
}
// Check expiry is ~24h from now
if claims.ExpiresAt == nil {
t.Fatal("ExpiresAt is nil")
}
expDelta := time.Until(claims.ExpiresAt.Time)
if expDelta < 23*time.Hour || expDelta > 25*time.Hour {
t.Errorf("ExpiresAt delta = %v, want ~24h", expDelta)
}
}
func TestHashPassword(t *testing.T) {
password := "correct-horse-battery-staple"
hash, err := hashPassword(password)
if err != nil {
t.Fatalf("hashPassword: %v", err)
}
if hash == "" {
t.Fatal("hashPassword returned empty hash")
}
if hash == password {
t.Fatal("hash must not equal plaintext password")
}
// Correct password verifies
if !checkPassword(hash, password) {
t.Error("checkPassword(hash, correct) = false, want true")
}
// Wrong password does not verify
if checkPassword(hash, "wrong-password") {
t.Error("checkPassword(hash, wrong) = true, want false")
}
}
func TestGenerateAPIKey(t *testing.T) {
key, hash, err := generateAPIKeyToken()
if err != nil {
t.Fatalf("generateAPIKeyToken: %v", err)
}
// Key has "lux_" prefix
if !strings.HasPrefix(key, "lux_") {
t.Errorf("key prefix = %q, want lux_", key[:4])
}
// Key is lux_ + 64 hex chars = 68 chars total
if len(key) != 68 {
t.Errorf("key length = %d, want 68", len(key))
}
// Hash is a 64-char hex string (SHA-256)
if len(hash) != 64 {
t.Errorf("hash length = %d, want 64", len(hash))
}
// Two calls produce different keys
key2, hash2, err := generateAPIKeyToken()
if err != nil {
t.Fatalf("generateAPIKeyToken (2nd call): %v", err)
}
if key == key2 {
t.Error("two calls produced identical keys")
}
if hash == hash2 {
t.Error("two calls produced identical hashes")
}
}
func TestInvalidJWT_WrongSecret(t *testing.T) {
s1 := newTestServer("secret-one")
s2 := newTestServer("secret-two")
token, err := s1.generateJWT("user-1", "org-1", "admin")
if err != nil {
t.Fatalf("generateJWT: %v", err)
}
// Validate with different secret must fail
_, err = s2.validateJWT(token)
if err == nil {
t.Fatal("validateJWT with wrong secret should fail")
}
}
func TestInvalidJWT_Expired(t *testing.T) {
s := newTestServer("test-secret")
// Manually create an expired token
claims := JWTClaims{
UserID: "user-exp",
OrgID: "org-exp",
Role: "viewer",
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(-1 * time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now().Add(-2 * time.Hour)),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenStr, err := token.SignedString(s.jwtSecret)
if err != nil {
t.Fatalf("sign expired token: %v", err)
}
_, err = s.validateJWT(tokenStr)
if err == nil {
t.Fatal("validateJWT should reject expired token")
}
}
func TestInvalidJWT_Malformed(t *testing.T) {
s := newTestServer("test-secret")
_, err := s.validateJWT("not-a-jwt-token")
if err == nil {
t.Fatal("validateJWT should reject malformed token")
}
}
func TestInvalidJWT_EmptyString(t *testing.T) {
s := newTestServer("test-secret")
_, err := s.validateJWT("")
if err == nil {
t.Fatal("validateJWT should reject empty string")
}
}
+54
View File
@@ -0,0 +1,54 @@
package api
import (
"net/http"
"github.com/luxfi/mpc/pkg/db"
)
func (s *Server) handleListAudit(w http.ResponseWriter, r *http.Request) {
orgID := getOrgID(r.Context())
rows, err := s.db.Pool.Query(r.Context(),
`SELECT id, org_id, user_id, action, resource_type, resource_id,
details, ip_address, created_at
FROM audit_log WHERE org_id = $1 ORDER BY created_at DESC LIMIT 200`, orgID)
if err != nil {
writeError(w, http.StatusInternalServerError, "database error")
return
}
defer rows.Close()
var entries []db.AuditEntry
for rows.Next() {
var e db.AuditEntry
if err := rows.Scan(&e.ID, &e.OrgID, &e.UserID, &e.Action,
&e.ResourceType, &e.ResourceID, &e.Details, &e.IPAddress,
&e.CreatedAt); err != nil {
continue
}
entries = append(entries, e)
}
if entries == nil {
entries = []db.AuditEntry{}
}
writeJSON(w, http.StatusOK, entries)
}
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
status := s.mpc.GetClusterStatus()
if status == nil {
writeJSON(w, http.StatusOK, map[string]string{"status": "unknown"})
return
}
writeJSON(w, http.StatusOK, status)
}
func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]interface{}{
"name": "Lux MPC",
"version": "0.3.3",
"supported_chains": []string{"ethereum", "bitcoin", "solana", "lux"},
"key_types": []string{"secp256k1", "ed25519"},
"protocols": []string{"CGGMP21", "FROST", "LSS"},
})
}
+225
View File
@@ -0,0 +1,225 @@
package api
import (
"crypto/rand"
"encoding/base32"
"encoding/json"
"net/http"
"regexp"
"strings"
)
func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
var req struct {
OrgName string `json:"org_name"`
Email string `json:"email"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.OrgName == "" || req.Email == "" || req.Password == "" {
writeError(w, http.StatusBadRequest, "org_name, email, and password are required")
return
}
if len(req.Password) < 8 {
writeError(w, http.StatusBadRequest, "password must be at least 8 characters")
return
}
if !isValidEmail(req.Email) {
writeError(w, http.StatusBadRequest, "invalid email address")
return
}
slug := slugify(req.OrgName)
hash, err := hashPassword(req.Password)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to hash password")
return
}
tx, err := s.db.Pool.Begin(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "database error")
return
}
defer tx.Rollback(r.Context())
var orgID string
err = tx.QueryRow(r.Context(),
`INSERT INTO organizations (name, slug) VALUES ($1, $2) RETURNING id`,
req.OrgName, slug).Scan(&orgID)
if err != nil {
writeError(w, http.StatusConflict, "organization already exists")
return
}
var userID string
err = tx.QueryRow(r.Context(),
`INSERT INTO users (org_id, email, password_hash, role) VALUES ($1, $2, $3, 'owner') RETURNING id`,
orgID, req.Email, hash).Scan(&userID)
if err != nil {
writeError(w, http.StatusConflict, "email already registered")
return
}
if err := tx.Commit(r.Context()); err != nil {
writeError(w, http.StatusInternalServerError, "database error")
return
}
accessToken, err := s.generateJWT(userID, orgID, "owner")
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to generate token")
return
}
refreshToken, err := s.generateRefreshToken(userID, orgID, "owner")
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to generate refresh token")
return
}
writeJSON(w, http.StatusCreated, map[string]interface{}{
"user_id": userID,
"org_id": orgID,
"access_token": accessToken,
"refresh_token": refreshToken,
})
}
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
var req struct {
Email string `json:"email"`
Password string `json:"password"`
MFACode string `json:"mfa_code,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
var userID, orgID, role, passwordHash string
var mfaSecret *string
err := s.db.Pool.QueryRow(r.Context(),
`SELECT u.id, u.org_id, u.role, u.password_hash, u.mfa_secret
FROM users u WHERE u.email = $1`, req.Email).
Scan(&userID, &orgID, &role, &passwordHash, &mfaSecret)
if err != nil {
writeError(w, http.StatusUnauthorized, "invalid credentials")
return
}
if !checkPassword(passwordHash, req.Password) {
writeError(w, http.StatusUnauthorized, "invalid credentials")
return
}
if mfaSecret != nil && *mfaSecret != "" {
if req.MFACode == "" {
writeJSON(w, http.StatusOK, map[string]interface{}{
"mfa_required": true,
})
return
}
// TODO: validate TOTP code against mfaSecret
}
accessToken, err := s.generateJWT(userID, orgID, role)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to generate token")
return
}
refreshToken, err := s.generateRefreshToken(userID, orgID, role)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to generate refresh token")
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"user_id": userID,
"org_id": orgID,
"role": role,
"access_token": accessToken,
"refresh_token": refreshToken,
})
}
func (s *Server) handleRefresh(w http.ResponseWriter, r *http.Request) {
var req struct {
RefreshToken string `json:"refresh_token"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
claims, err := s.validateJWT(req.RefreshToken)
if err != nil {
writeError(w, http.StatusUnauthorized, "invalid refresh token")
return
}
accessToken, err := s.generateJWT(claims.UserID, claims.OrgID, claims.Role)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to generate token")
return
}
refreshToken, err := s.generateRefreshToken(claims.UserID, claims.OrgID, claims.Role)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to generate refresh token")
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"access_token": accessToken,
"refresh_token": refreshToken,
})
}
func (s *Server) handleMFASetup(w http.ResponseWriter, r *http.Request) {
userID := getUserID(r.Context())
secret := make([]byte, 20)
if _, err := rand.Read(secret); err != nil {
writeError(w, http.StatusInternalServerError, "failed to generate secret")
return
}
encoded := base32.StdEncoding.EncodeToString(secret)
_, err := s.db.Pool.Exec(r.Context(),
`UPDATE users SET mfa_secret = $1 WHERE id = $2`, encoded, userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "database error")
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"secret": encoded,
"otpauth": "otpauth://totp/LuxMPC?secret=" + encoded + "&issuer=LuxMPC",
})
}
func (s *Server) handleMFAVerify(w http.ResponseWriter, r *http.Request) {
var req struct {
Code string `json:"code"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
// TODO: Implement TOTP verification
writeJSON(w, http.StatusOK, map[string]string{"status": "verified"})
}
func slugify(name string) string {
s := strings.ToLower(name)
s = regexp.MustCompile(`[^a-z0-9-]+`).ReplaceAllString(s, "-")
s = strings.Trim(s, "-")
return s
}
func isValidEmail(email string) bool {
return regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`).MatchString(email)
}
+215
View File
@@ -0,0 +1,215 @@
package api
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"net/http"
"time"
"github.com/luxfi/mpc/pkg/db"
)
func (s *Server) handleCreatePaymentRequest(w http.ResponseWriter, r *http.Request) {
orgID := getOrgID(r.Context())
var req struct {
WalletID *string `json:"wallet_id,omitempty"`
MerchantName *string `json:"merchant_name,omitempty"`
RecipientAddress string `json:"recipient_address"`
Chain string `json:"chain"`
Token *string `json:"token,omitempty"`
Amount string `json:"amount"`
Memo *string `json:"memo,omitempty"`
ExpiresInHours int `json:"expires_in_hours,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.RecipientAddress == "" || req.Chain == "" || req.Amount == "" {
writeError(w, http.StatusBadRequest, "recipient_address, chain, and amount are required")
return
}
token := generateRequestToken()
var expiresAt *time.Time
if req.ExpiresInHours > 0 {
t := time.Now().Add(time.Duration(req.ExpiresInHours) * time.Hour)
expiresAt = &t
}
var pr db.PaymentRequest
err := s.db.Pool.QueryRow(r.Context(),
`INSERT INTO payment_requests (org_id, wallet_id, request_token, merchant_name,
recipient_address, chain, token, amount, memo, expires_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING id, org_id, wallet_id, request_token, merchant_name,
recipient_address, chain, token, amount, memo, status, expires_at, created_at`,
orgID, req.WalletID, token, req.MerchantName,
req.RecipientAddress, req.Chain, req.Token, req.Amount, req.Memo, expiresAt).
Scan(&pr.ID, &pr.OrgID, &pr.WalletID, &pr.RequestToken, &pr.MerchantName,
&pr.RecipientAddress, &pr.Chain, &pr.Token, &pr.Amount, &pr.Memo,
&pr.Status, &pr.ExpiresAt, &pr.CreatedAt)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create payment request: "+err.Error())
return
}
writeJSON(w, http.StatusCreated, map[string]interface{}{
"payment_request": pr,
"payment_url": "https://mpc.lux.network/pay/" + token,
})
}
func (s *Server) handleListPaymentRequests(w http.ResponseWriter, r *http.Request) {
orgID := getOrgID(r.Context())
rows, err := s.db.Pool.Query(r.Context(),
`SELECT id, org_id, wallet_id, request_token, merchant_name,
recipient_address, chain, token, amount, memo,
status, expires_at, paid_tx_id, created_at
FROM payment_requests WHERE org_id = $1 ORDER BY created_at DESC`, orgID)
if err != nil {
writeError(w, http.StatusInternalServerError, "database error")
return
}
defer rows.Close()
var requests []db.PaymentRequest
for rows.Next() {
var pr db.PaymentRequest
if err := rows.Scan(&pr.ID, &pr.OrgID, &pr.WalletID, &pr.RequestToken,
&pr.MerchantName, &pr.RecipientAddress, &pr.Chain, &pr.Token,
&pr.Amount, &pr.Memo, &pr.Status, &pr.ExpiresAt, &pr.PaidTxID,
&pr.CreatedAt); err != nil {
continue
}
requests = append(requests, pr)
}
if requests == nil {
requests = []db.PaymentRequest{}
}
writeJSON(w, http.StatusOK, requests)
}
func (s *Server) handleGetPaymentRequest(w http.ResponseWriter, r *http.Request) {
orgID := getOrgID(r.Context())
prID := urlParam(r, "id")
var pr db.PaymentRequest
err := s.db.Pool.QueryRow(r.Context(),
`SELECT id, org_id, wallet_id, request_token, merchant_name,
recipient_address, chain, token, amount, memo,
status, expires_at, paid_tx_id, created_at
FROM payment_requests WHERE id = $1 AND org_id = $2`, prID, orgID).
Scan(&pr.ID, &pr.OrgID, &pr.WalletID, &pr.RequestToken,
&pr.MerchantName, &pr.RecipientAddress, &pr.Chain, &pr.Token,
&pr.Amount, &pr.Memo, &pr.Status, &pr.ExpiresAt, &pr.PaidTxID,
&pr.CreatedAt)
if err != nil {
writeError(w, http.StatusNotFound, "payment request not found")
return
}
writeJSON(w, http.StatusOK, pr)
}
func (s *Server) handlePayPaymentRequest(w http.ResponseWriter, r *http.Request) {
orgID := getOrgID(r.Context())
prID := urlParam(r, "id")
var req struct {
WalletID string `json:"wallet_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
var pr db.PaymentRequest
err := s.db.Pool.QueryRow(r.Context(),
`SELECT id, recipient_address, chain, token, amount, status, expires_at
FROM payment_requests WHERE id = $1 AND org_id = $2`, prID, orgID).
Scan(&pr.ID, &pr.RecipientAddress, &pr.Chain, &pr.Token, &pr.Amount,
&pr.Status, &pr.ExpiresAt)
if err != nil {
writeError(w, http.StatusNotFound, "payment request not found")
return
}
if pr.Status != "pending" {
writeError(w, http.StatusBadRequest, "payment request is not pending")
return
}
if pr.ExpiresAt != nil && pr.ExpiresAt.Before(time.Now()) {
writeError(w, http.StatusBadRequest, "payment request has expired")
return
}
tokenStr := ""
if pr.Token != nil {
tokenStr = *pr.Token
}
var txID string
err = s.db.Pool.QueryRow(r.Context(),
`INSERT INTO transactions (org_id, wallet_id, tx_type, chain, to_address, amount, token, status, initiated_by)
VALUES ($1, $2, 'payment', $3, $4, $5, $6, 'approved', $7)
RETURNING id`,
orgID, req.WalletID, pr.Chain, pr.RecipientAddress, pr.Amount,
nilIfEmpty(tokenStr), getUserID(r.Context())).Scan(&txID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create transaction")
return
}
s.db.Pool.Exec(r.Context(),
`UPDATE payment_requests SET status = 'paid', paid_tx_id = $1 WHERE id = $2`,
txID, prID)
go s.signAndBroadcast(txID, orgID)
writeJSON(w, http.StatusOK, map[string]string{
"status": "payment_initiated",
"tx_id": txID,
})
}
// handlePublicPay serves the public payment page (no auth required)
func (s *Server) handlePublicPay(w http.ResponseWriter, r *http.Request) {
token := urlParam(r, "token")
var pr db.PaymentRequest
err := s.db.Pool.QueryRow(r.Context(),
`SELECT id, merchant_name, recipient_address, chain, token, amount, memo,
status, expires_at, created_at
FROM payment_requests WHERE request_token = $1`, token).
Scan(&pr.ID, &pr.MerchantName, &pr.RecipientAddress, &pr.Chain,
&pr.Token, &pr.Amount, &pr.Memo, &pr.Status, &pr.ExpiresAt, &pr.CreatedAt)
if err != nil {
writeError(w, http.StatusNotFound, "payment request not found")
return
}
if pr.Status != "pending" {
writeJSON(w, http.StatusOK, map[string]interface{}{
"status": pr.Status,
"message": "this payment request is no longer active",
})
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"merchant_name": pr.MerchantName,
"recipient_address": pr.RecipientAddress,
"chain": pr.Chain,
"token": pr.Token,
"amount": pr.Amount,
"memo": pr.Memo,
"expires_at": pr.ExpiresAt,
})
}
func generateRequestToken() string {
b := make([]byte, 16)
rand.Read(b)
return hex.EncodeToString(b)
}
+130
View File
@@ -0,0 +1,130 @@
package api
import (
"encoding/json"
"net/http"
"github.com/luxfi/mpc/pkg/db"
)
func (s *Server) handleListPolicies(w http.ResponseWriter, r *http.Request) {
orgID := getOrgID(r.Context())
rows, err := s.db.Pool.Query(r.Context(),
`SELECT id, org_id, vault_id, name, priority, action, conditions,
required_approvers, approver_roles, enabled, created_at
FROM policies WHERE org_id = $1 ORDER BY priority DESC`, orgID)
if err != nil {
writeError(w, http.StatusInternalServerError, "database error")
return
}
defer rows.Close()
var policies []db.Policy
for rows.Next() {
var p db.Policy
if err := rows.Scan(&p.ID, &p.OrgID, &p.VaultID, &p.Name, &p.Priority,
&p.Action, &p.Conditions, &p.RequiredApprovers, &p.ApproverRoles,
&p.Enabled, &p.CreatedAt); err != nil {
continue
}
policies = append(policies, p)
}
if policies == nil {
policies = []db.Policy{}
}
writeJSON(w, http.StatusOK, policies)
}
func (s *Server) handleCreatePolicy(w http.ResponseWriter, r *http.Request) {
orgID := getOrgID(r.Context())
var req struct {
VaultID *string `json:"vault_id,omitempty"`
Name string `json:"name"`
Priority int `json:"priority"`
Action string `json:"action"`
Conditions json.RawMessage `json:"conditions"`
RequiredApprovers int `json:"required_approvers"`
ApproverRoles []string `json:"approver_roles"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Name == "" || req.Action == "" {
writeError(w, http.StatusBadRequest, "name and action are required")
return
}
var policy db.Policy
err := s.db.Pool.QueryRow(r.Context(),
`INSERT INTO policies (org_id, vault_id, name, priority, action, conditions, required_approvers, approver_roles)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, org_id, vault_id, name, priority, action, conditions,
required_approvers, approver_roles, enabled, created_at`,
orgID, req.VaultID, req.Name, req.Priority, req.Action,
[]byte(req.Conditions), req.RequiredApprovers, req.ApproverRoles).
Scan(&policy.ID, &policy.OrgID, &policy.VaultID, &policy.Name, &policy.Priority,
&policy.Action, &policy.Conditions, &policy.RequiredApprovers, &policy.ApproverRoles,
&policy.Enabled, &policy.CreatedAt)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create policy: "+err.Error())
return
}
writeJSON(w, http.StatusCreated, policy)
}
func (s *Server) handleUpdatePolicy(w http.ResponseWriter, r *http.Request) {
orgID := getOrgID(r.Context())
policyID := urlParam(r, "id")
var req struct {
Name *string `json:"name,omitempty"`
Priority *int `json:"priority,omitempty"`
Action *string `json:"action,omitempty"`
Conditions *json.RawMessage `json:"conditions,omitempty"`
RequiredApprovers *int `json:"required_approvers,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
var policy db.Policy
err := s.db.Pool.QueryRow(r.Context(),
`UPDATE policies SET
name = COALESCE($1, name),
priority = COALESCE($2, priority),
action = COALESCE($3, action),
conditions = COALESCE($4, conditions),
required_approvers = COALESCE($5, required_approvers),
enabled = COALESCE($6, enabled)
WHERE id = $7 AND org_id = $8
RETURNING id, org_id, vault_id, name, priority, action, conditions,
required_approvers, approver_roles, enabled, created_at`,
req.Name, req.Priority, req.Action, req.Conditions,
req.RequiredApprovers, req.Enabled, policyID, orgID).
Scan(&policy.ID, &policy.OrgID, &policy.VaultID, &policy.Name, &policy.Priority,
&policy.Action, &policy.Conditions, &policy.RequiredApprovers, &policy.ApproverRoles,
&policy.Enabled, &policy.CreatedAt)
if err != nil {
writeError(w, http.StatusNotFound, "policy not found")
return
}
writeJSON(w, http.StatusOK, policy)
}
func (s *Server) handleDeletePolicy(w http.ResponseWriter, r *http.Request) {
orgID := getOrgID(r.Context())
policyID := urlParam(r, "id")
tag, err := s.db.Pool.Exec(r.Context(),
`DELETE FROM policies WHERE id = $1 AND org_id = $2`, policyID, orgID)
if err != nil || tag.RowsAffected() == 0 {
writeError(w, http.StatusNotFound, "policy not found")
return
}
w.WriteHeader(http.StatusNoContent)
}
+211
View File
@@ -0,0 +1,211 @@
package api
import (
"encoding/json"
"net/http"
"github.com/luxfi/mpc/pkg/db"
)
func (s *Server) handleDeploySmartWallet(w http.ResponseWriter, r *http.Request) {
orgID := getOrgID(r.Context())
walletID := urlParam(r, "id")
var req struct {
Chain string `json:"chain"`
WalletType string `json:"wallet_type"` // safe, erc4337
FactoryAddress *string `json:"factory_address,omitempty"`
EntrypointAddress *string `json:"entrypoint_address,omitempty"`
Salt *string `json:"salt,omitempty"`
Owners []string `json:"owners"`
Threshold int `json:"threshold"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Chain == "" || req.WalletType == "" || len(req.Owners) == 0 || req.Threshold == 0 {
writeError(w, http.StatusBadRequest, "chain, wallet_type, owners, and threshold are required")
return
}
// Verify wallet belongs to org
var ethAddress *string
err := s.db.Pool.QueryRow(r.Context(),
`SELECT eth_address FROM wallets WHERE id = $1 AND org_id = $2`,
walletID, orgID).Scan(&ethAddress)
if err != nil {
writeError(w, http.StatusNotFound, "wallet not found")
return
}
// TODO: Deploy smart wallet via Safe SDK or ERC-4337 factory
// For now, record the intent
contractAddress := "0x0000000000000000000000000000000000000000" // placeholder
var sw db.SmartWallet
err = s.db.Pool.QueryRow(r.Context(),
`INSERT INTO smart_wallets (wallet_id, org_id, chain, contract_address, wallet_type,
factory_address, entrypoint_address, salt, owners, threshold)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING id, wallet_id, org_id, chain, contract_address, wallet_type,
factory_address, entrypoint_address, salt, owners, threshold, status, deployed_at, created_at`,
walletID, orgID, req.Chain, contractAddress, req.WalletType,
req.FactoryAddress, req.EntrypointAddress, req.Salt, req.Owners, req.Threshold).
Scan(&sw.ID, &sw.WalletID, &sw.OrgID, &sw.Chain, &sw.ContractAddress,
&sw.WalletType, &sw.FactoryAddress, &sw.EntrypointAddress, &sw.Salt,
&sw.Owners, &sw.Threshold, &sw.Status, &sw.DeployedAt, &sw.CreatedAt)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create smart wallet: "+err.Error())
return
}
writeJSON(w, http.StatusCreated, sw)
}
func (s *Server) handleListSmartWallets(w http.ResponseWriter, r *http.Request) {
orgID := getOrgID(r.Context())
walletID := urlParam(r, "id")
rows, err := s.db.Pool.Query(r.Context(),
`SELECT id, wallet_id, org_id, chain, contract_address, wallet_type,
factory_address, entrypoint_address, salt, owners, threshold,
status, deployed_at, created_at
FROM smart_wallets WHERE wallet_id = $1 AND org_id = $2
ORDER BY created_at DESC`, walletID, orgID)
if err != nil {
writeError(w, http.StatusInternalServerError, "database error")
return
}
defer rows.Close()
var wallets []db.SmartWallet
for rows.Next() {
var sw db.SmartWallet
if err := rows.Scan(&sw.ID, &sw.WalletID, &sw.OrgID, &sw.Chain,
&sw.ContractAddress, &sw.WalletType, &sw.FactoryAddress,
&sw.EntrypointAddress, &sw.Salt, &sw.Owners, &sw.Threshold,
&sw.Status, &sw.DeployedAt, &sw.CreatedAt); err != nil {
continue
}
wallets = append(wallets, sw)
}
if wallets == nil {
wallets = []db.SmartWallet{}
}
writeJSON(w, http.StatusOK, wallets)
}
func (s *Server) handleGetSmartWallet(w http.ResponseWriter, r *http.Request) {
orgID := getOrgID(r.Context())
swID := urlParam(r, "id")
var sw db.SmartWallet
err := s.db.Pool.QueryRow(r.Context(),
`SELECT id, wallet_id, org_id, chain, contract_address, wallet_type,
factory_address, entrypoint_address, salt, owners, threshold,
status, deployed_at, created_at
FROM smart_wallets WHERE id = $1 AND org_id = $2`, swID, orgID).
Scan(&sw.ID, &sw.WalletID, &sw.OrgID, &sw.Chain, &sw.ContractAddress,
&sw.WalletType, &sw.FactoryAddress, &sw.EntrypointAddress, &sw.Salt,
&sw.Owners, &sw.Threshold, &sw.Status, &sw.DeployedAt, &sw.CreatedAt)
if err != nil {
writeError(w, http.StatusNotFound, "smart wallet not found")
return
}
writeJSON(w, http.StatusOK, sw)
}
func (s *Server) handleProposeSafeTx(w http.ResponseWriter, r *http.Request) {
orgID := getOrgID(r.Context())
swID := urlParam(r, "id")
var req struct {
To string `json:"to"`
Value string `json:"value"`
Data string `json:"data,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
// Verify smart wallet
var walletID, chain string
err := s.db.Pool.QueryRow(r.Context(),
`SELECT wallet_id, chain FROM smart_wallets WHERE id = $1 AND org_id = $2`,
swID, orgID).Scan(&walletID, &chain)
if err != nil {
writeError(w, http.StatusNotFound, "smart wallet not found")
return
}
// TODO: Create Safe transaction proposal, sign with MPC
writeJSON(w, http.StatusOK, map[string]string{
"status": "proposed",
"smart_wallet_id": swID,
"to": req.To,
"value": req.Value,
})
}
func (s *Server) handleExecuteSafeTx(w http.ResponseWriter, r *http.Request) {
orgID := getOrgID(r.Context())
swID := urlParam(r, "id")
var req struct {
SafeTxHash string `json:"safe_tx_hash"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
var contractAddress string
err := s.db.Pool.QueryRow(r.Context(),
`SELECT contract_address FROM smart_wallets WHERE id = $1 AND org_id = $2`,
swID, orgID).Scan(&contractAddress)
if err != nil {
writeError(w, http.StatusNotFound, "smart wallet not found")
return
}
// TODO: Execute Safe transaction
writeJSON(w, http.StatusOK, map[string]string{
"status": "executing",
"safe_tx_hash": req.SafeTxHash,
})
}
func (s *Server) handleUserOperation(w http.ResponseWriter, r *http.Request) {
orgID := getOrgID(r.Context())
swID := urlParam(r, "id")
var req struct {
CallData string `json:"call_data"`
Value string `json:"value,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
var walletType string
err := s.db.Pool.QueryRow(r.Context(),
`SELECT wallet_type FROM smart_wallets WHERE id = $1 AND org_id = $2`,
swID, orgID).Scan(&walletType)
if err != nil {
writeError(w, http.StatusNotFound, "smart wallet not found")
return
}
if walletType != "erc4337" {
writeError(w, http.StatusBadRequest, "user operations only supported for ERC-4337 wallets")
return
}
// TODO: Build and submit UserOperation
writeJSON(w, http.StatusOK, map[string]string{
"status": "submitted",
"smart_wallet_id": swID,
})
}
+234
View File
@@ -0,0 +1,234 @@
package api
import (
"encoding/json"
"net/http"
"time"
"github.com/luxfi/mpc/pkg/db"
)
func (s *Server) handleListSubscriptions(w http.ResponseWriter, r *http.Request) {
orgID := getOrgID(r.Context())
rows, err := s.db.Pool.Query(r.Context(),
`SELECT id, org_id, wallet_id, name, provider_name, recipient_address,
chain, token, amount, currency, interval,
next_payment_at, last_payment_at, last_tx_id,
status, max_retries, retry_count, require_balance,
created_by, created_at
FROM subscriptions WHERE org_id = $1 ORDER BY created_at DESC`, orgID)
if err != nil {
writeError(w, http.StatusInternalServerError, "database error")
return
}
defer rows.Close()
var subs []db.Subscription
for rows.Next() {
var sub db.Subscription
if err := rows.Scan(&sub.ID, &sub.OrgID, &sub.WalletID, &sub.Name,
&sub.ProviderName, &sub.RecipientAddress, &sub.Chain, &sub.Token,
&sub.Amount, &sub.Currency, &sub.Interval,
&sub.NextPaymentAt, &sub.LastPaymentAt, &sub.LastTxID,
&sub.Status, &sub.MaxRetries, &sub.RetryCount, &sub.RequireBalance,
&sub.CreatedBy, &sub.CreatedAt); err != nil {
continue
}
subs = append(subs, sub)
}
if subs == nil {
subs = []db.Subscription{}
}
writeJSON(w, http.StatusOK, subs)
}
func (s *Server) handleCreateSubscription(w http.ResponseWriter, r *http.Request) {
orgID := getOrgID(r.Context())
userID := getUserID(r.Context())
var req struct {
WalletID string `json:"wallet_id"`
Name string `json:"name"`
ProviderName *string `json:"provider_name,omitempty"`
RecipientAddress string `json:"recipient_address"`
Chain string `json:"chain"`
Token *string `json:"token,omitempty"`
Amount string `json:"amount"`
Currency string `json:"currency"`
Interval string `json:"interval"`
RequireBalance bool `json:"require_balance"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Name == "" || req.RecipientAddress == "" || req.Chain == "" || req.Amount == "" || req.Interval == "" {
writeError(w, http.StatusBadRequest, "name, recipient_address, chain, amount, and interval are required")
return
}
if req.Currency == "" {
req.Currency = "USD"
}
nextPayment := computeNextPayment(req.Interval)
var sub db.Subscription
err := s.db.Pool.QueryRow(r.Context(),
`INSERT INTO subscriptions (org_id, wallet_id, name, provider_name, recipient_address,
chain, token, amount, currency, interval, next_payment_at, require_balance, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
RETURNING id, org_id, wallet_id, name, provider_name, recipient_address,
chain, token, amount, currency, interval,
next_payment_at, last_payment_at, last_tx_id,
status, max_retries, retry_count, require_balance, created_by, created_at`,
orgID, req.WalletID, req.Name, req.ProviderName, req.RecipientAddress,
req.Chain, req.Token, req.Amount, req.Currency, req.Interval,
nextPayment, req.RequireBalance, userID).
Scan(&sub.ID, &sub.OrgID, &sub.WalletID, &sub.Name,
&sub.ProviderName, &sub.RecipientAddress, &sub.Chain, &sub.Token,
&sub.Amount, &sub.Currency, &sub.Interval,
&sub.NextPaymentAt, &sub.LastPaymentAt, &sub.LastTxID,
&sub.Status, &sub.MaxRetries, &sub.RetryCount, &sub.RequireBalance,
&sub.CreatedBy, &sub.CreatedAt)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create subscription: "+err.Error())
return
}
writeJSON(w, http.StatusCreated, sub)
}
func (s *Server) handleGetSubscription(w http.ResponseWriter, r *http.Request) {
orgID := getOrgID(r.Context())
subID := urlParam(r, "id")
var sub db.Subscription
err := s.db.Pool.QueryRow(r.Context(),
`SELECT id, org_id, wallet_id, name, provider_name, recipient_address,
chain, token, amount, currency, interval,
next_payment_at, last_payment_at, last_tx_id,
status, max_retries, retry_count, require_balance,
created_by, created_at
FROM subscriptions WHERE id = $1 AND org_id = $2`, subID, orgID).
Scan(&sub.ID, &sub.OrgID, &sub.WalletID, &sub.Name,
&sub.ProviderName, &sub.RecipientAddress, &sub.Chain, &sub.Token,
&sub.Amount, &sub.Currency, &sub.Interval,
&sub.NextPaymentAt, &sub.LastPaymentAt, &sub.LastTxID,
&sub.Status, &sub.MaxRetries, &sub.RetryCount, &sub.RequireBalance,
&sub.CreatedBy, &sub.CreatedAt)
if err != nil {
writeError(w, http.StatusNotFound, "subscription not found")
return
}
writeJSON(w, http.StatusOK, sub)
}
func (s *Server) handleUpdateSubscription(w http.ResponseWriter, r *http.Request) {
orgID := getOrgID(r.Context())
subID := urlParam(r, "id")
var req struct {
Status *string `json:"status,omitempty"`
Amount *string `json:"amount,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
var sub db.Subscription
err := s.db.Pool.QueryRow(r.Context(),
`UPDATE subscriptions SET
status = COALESCE($1, status),
amount = COALESCE($2, amount)
WHERE id = $3 AND org_id = $4
RETURNING id, org_id, wallet_id, name, provider_name, recipient_address,
chain, token, amount, currency, interval,
next_payment_at, last_payment_at, last_tx_id,
status, max_retries, retry_count, require_balance, created_by, created_at`,
req.Status, req.Amount, subID, orgID).
Scan(&sub.ID, &sub.OrgID, &sub.WalletID, &sub.Name,
&sub.ProviderName, &sub.RecipientAddress, &sub.Chain, &sub.Token,
&sub.Amount, &sub.Currency, &sub.Interval,
&sub.NextPaymentAt, &sub.LastPaymentAt, &sub.LastTxID,
&sub.Status, &sub.MaxRetries, &sub.RetryCount, &sub.RequireBalance,
&sub.CreatedBy, &sub.CreatedAt)
if err != nil {
writeError(w, http.StatusNotFound, "subscription not found")
return
}
writeJSON(w, http.StatusOK, sub)
}
func (s *Server) handleDeleteSubscription(w http.ResponseWriter, r *http.Request) {
orgID := getOrgID(r.Context())
userID := getUserID(r.Context())
subID := urlParam(r, "id")
now := time.Now()
tag, err := s.db.Pool.Exec(r.Context(),
`UPDATE subscriptions SET status = 'cancelled', cancelled_by = $1, cancelled_at = $2
WHERE id = $3 AND org_id = $4 AND status != 'cancelled'`,
userID, now, subID, orgID)
if err != nil || tag.RowsAffected() == 0 {
writeError(w, http.StatusNotFound, "subscription not found")
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handlePayNow(w http.ResponseWriter, r *http.Request) {
orgID := getOrgID(r.Context())
subID := urlParam(r, "id")
var walletID, recipientAddress, chain, amount string
var token *string
err := s.db.Pool.QueryRow(r.Context(),
`SELECT wallet_id, recipient_address, chain, amount, token
FROM subscriptions WHERE id = $1 AND org_id = $2 AND status = 'active'`,
subID, orgID).Scan(&walletID, &recipientAddress, &chain, &amount, &token)
if err != nil {
writeError(w, http.StatusNotFound, "subscription not found or not active")
return
}
// Create transaction for immediate payment
var txID string
tokenStr := ""
if token != nil {
tokenStr = *token
}
err = s.db.Pool.QueryRow(r.Context(),
`INSERT INTO transactions (org_id, wallet_id, tx_type, chain, to_address, amount, token, status, initiated_by)
VALUES ($1, $2, 'subscription_payment', $3, $4, $5, $6, 'approved', $7)
RETURNING id`,
orgID, walletID, chain, recipientAddress, amount, nilIfEmpty(tokenStr),
getUserID(r.Context())).Scan(&txID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create payment transaction")
return
}
go s.signAndBroadcast(txID, orgID)
writeJSON(w, http.StatusOK, map[string]string{
"status": "payment_initiated",
"tx_id": txID,
})
}
func computeNextPayment(interval string) time.Time {
now := time.Now()
switch interval {
case "daily":
return now.Add(24 * time.Hour)
case "weekly":
return now.Add(7 * 24 * time.Hour)
case "monthly":
return now.AddDate(0, 1, 0)
case "yearly":
return now.AddDate(1, 0, 0)
default:
return now.Add(30 * 24 * time.Hour)
}
}
+262
View File
@@ -0,0 +1,262 @@
package api
import (
"context"
"encoding/hex"
"encoding/json"
"net/http"
"strconv"
"time"
"github.com/luxfi/mpc/pkg/db"
)
func (s *Server) handleCreateTransaction(w http.ResponseWriter, r *http.Request) {
orgID := getOrgID(r.Context())
userID := getUserID(r.Context())
var req struct {
WalletID string `json:"wallet_id"`
TxType string `json:"tx_type"`
Chain string `json:"chain"`
ToAddress string `json:"to_address"`
Amount string `json:"amount"`
Token string `json:"token,omitempty"`
RawTx string `json:"raw_tx,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.WalletID == "" || req.Chain == "" || req.TxType == "" {
writeError(w, http.StatusBadRequest, "wallet_id, tx_type, and chain are required")
return
}
// Verify wallet
var walletOrgID string
err := s.db.Pool.QueryRow(r.Context(),
`SELECT org_id FROM wallets WHERE id = $1`, req.WalletID).Scan(&walletOrgID)
if err != nil || walletOrgID != orgID {
writeError(w, http.StatusNotFound, "wallet not found")
return
}
// Run policy engine
policies, err := s.loadPolicies(r.Context(), orgID, nil)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load policies")
return
}
decision := evaluateTransaction(req.Amount, req.Chain, req.ToAddress, policies)
status := "pending_approval"
if decision.Action == "approve" {
status = "approved"
} else if decision.Action == "deny" {
writeError(w, http.StatusForbidden, "transaction denied by policy: "+decision.Reason)
return
}
var rawTx []byte
if req.RawTx != "" {
rawTx, _ = hex.DecodeString(req.RawTx)
}
var tx db.Transaction
err = s.db.Pool.QueryRow(r.Context(),
`INSERT INTO transactions (org_id, wallet_id, tx_type, chain, to_address, amount, token, raw_tx, status, initiated_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING id, org_id, wallet_id, tx_type, chain, to_address, amount, token,
tx_hash, status, initiated_by, created_at`,
orgID, req.WalletID, req.TxType, req.Chain, req.ToAddress, req.Amount,
nilIfEmpty(req.Token), rawTx, status, userID).
Scan(&tx.ID, &tx.OrgID, &tx.WalletID, &tx.TxType, &tx.Chain,
&tx.ToAddress, &tx.Amount, &tx.Token, &tx.TxHash, &tx.Status,
&tx.InitiatedBy, &tx.CreatedAt)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create transaction: "+err.Error())
return
}
if status == "approved" {
go s.signAndBroadcast(tx.ID, orgID)
}
s.fireWebhook(r.Context(), orgID, "tx.pending", tx)
writeJSON(w, http.StatusCreated, tx)
}
func (s *Server) handleListTransactions(w http.ResponseWriter, r *http.Request) {
orgID := getOrgID(r.Context())
statusFilter := r.URL.Query().Get("status")
chain := r.URL.Query().Get("chain")
query := `SELECT id, org_id, wallet_id, tx_type, chain, to_address, amount, token,
tx_hash, status, initiated_by, created_at, signed_at, broadcast_at
FROM transactions WHERE org_id = $1`
args := []interface{}{orgID}
idx := 2
if statusFilter != "" {
query += " AND status = $" + strconv.Itoa(idx)
args = append(args, statusFilter)
idx++
}
if chain != "" {
query += " AND chain = $" + strconv.Itoa(idx)
args = append(args, chain)
idx++
}
query += " ORDER BY created_at DESC LIMIT 100"
rows, err := s.db.Pool.Query(r.Context(), query, args...)
if err != nil {
writeError(w, http.StatusInternalServerError, "database error")
return
}
defer rows.Close()
var txs []db.Transaction
for rows.Next() {
var tx db.Transaction
if err := rows.Scan(&tx.ID, &tx.OrgID, &tx.WalletID, &tx.TxType, &tx.Chain,
&tx.ToAddress, &tx.Amount, &tx.Token, &tx.TxHash, &tx.Status,
&tx.InitiatedBy, &tx.CreatedAt, &tx.SignedAt, &tx.BroadcastAt); err != nil {
continue
}
txs = append(txs, tx)
}
if txs == nil {
txs = []db.Transaction{}
}
writeJSON(w, http.StatusOK, txs)
}
func (s *Server) handleGetTransaction(w http.ResponseWriter, r *http.Request) {
orgID := getOrgID(r.Context())
txID := urlParam(r, "id")
var tx db.Transaction
err := s.db.Pool.QueryRow(r.Context(),
`SELECT id, org_id, wallet_id, tx_type, chain, to_address, amount, token,
tx_hash, signature_r, signature_s, signature_eddsa,
status, initiated_by, approved_by, rejected_by, rejection_reason,
created_at, signed_at, broadcast_at
FROM transactions WHERE id = $1 AND org_id = $2`, txID, orgID).
Scan(&tx.ID, &tx.OrgID, &tx.WalletID, &tx.TxType, &tx.Chain,
&tx.ToAddress, &tx.Amount, &tx.Token, &tx.TxHash,
&tx.SignatureR, &tx.SignatureS, &tx.SignatureEdDSA,
&tx.Status, &tx.InitiatedBy, &tx.ApprovedBy, &tx.RejectedBy, &tx.RejectionReason,
&tx.CreatedAt, &tx.SignedAt, &tx.BroadcastAt)
if err != nil {
writeError(w, http.StatusNotFound, "transaction not found")
return
}
writeJSON(w, http.StatusOK, tx)
}
func (s *Server) handleApproveTransaction(w http.ResponseWriter, r *http.Request) {
orgID := getOrgID(r.Context())
userID := getUserID(r.Context())
txID := urlParam(r, "id")
var status string
var approvedBy []string
err := s.db.Pool.QueryRow(r.Context(),
`UPDATE transactions
SET approved_by = array_append(approved_by, $1::uuid)
WHERE id = $2 AND org_id = $3 AND status = 'pending_approval'
RETURNING status, approved_by`, userID, txID, orgID).
Scan(&status, &approvedBy)
if err != nil {
writeError(w, http.StatusNotFound, "transaction not found or not pending approval")
return
}
policies, _ := s.loadPolicies(r.Context(), orgID, nil)
requiredApprovers := 1
for _, p := range policies {
if p.RequiredApprovers > requiredApprovers {
requiredApprovers = p.RequiredApprovers
}
}
if len(approvedBy) >= requiredApprovers {
s.db.Pool.Exec(r.Context(),
`UPDATE transactions SET status = 'approved' WHERE id = $1`, txID)
go s.signAndBroadcast(txID, orgID)
}
s.fireWebhook(r.Context(), orgID, "tx.approved", map[string]string{
"tx_id": txID,
"approved_by": userID,
})
writeJSON(w, http.StatusOK, map[string]string{"status": "approved"})
}
func (s *Server) handleRejectTransaction(w http.ResponseWriter, r *http.Request) {
orgID := getOrgID(r.Context())
userID := getUserID(r.Context())
txID := urlParam(r, "id")
var req struct {
Reason string `json:"reason"`
}
json.NewDecoder(r.Body).Decode(&req)
tag, err := s.db.Pool.Exec(r.Context(),
`UPDATE transactions SET status = 'rejected', rejected_by = $1, rejection_reason = $2
WHERE id = $3 AND org_id = $4 AND status = 'pending_approval'`,
userID, req.Reason, txID, orgID)
if err != nil || tag.RowsAffected() == 0 {
writeError(w, http.StatusNotFound, "transaction not found or not pending")
return
}
s.fireWebhook(r.Context(), orgID, "tx.rejected", map[string]string{
"tx_id": txID,
"reason": req.Reason,
})
writeJSON(w, http.StatusOK, map[string]string{"status": "rejected"})
}
func (s *Server) signAndBroadcast(txID, orgID string) {
ctx := context.Background()
var walletDBID string
var rawTx []byte
err := s.db.Pool.QueryRow(ctx,
`SELECT wallet_id, raw_tx FROM transactions WHERE id = $1`, txID).
Scan(&walletDBID, &rawTx)
if err != nil {
return
}
var mpcWalletID string
err = s.db.Pool.QueryRow(ctx,
`SELECT wallet_id FROM wallets WHERE id = $1`, walletDBID).
Scan(&mpcWalletID)
if err != nil {
return
}
s.db.Pool.Exec(ctx, `UPDATE transactions SET status = 'signing' WHERE id = $1`, txID)
result, err := s.mpc.TriggerSign(mpcWalletID, rawTx)
if err != nil {
s.db.Pool.Exec(ctx, `UPDATE transactions SET status = 'failed' WHERE id = $1`, txID)
s.fireWebhook(ctx, orgID, "tx.failed", map[string]string{"tx_id": txID, "error": err.Error()})
return
}
now := time.Now()
s.db.Pool.Exec(ctx,
`UPDATE transactions SET status = 'signed', signature_r = $1, signature_s = $2,
signature_eddsa = $3, signed_at = $4 WHERE id = $5`,
result.R, result.S, result.Signature, now, txID)
s.fireWebhook(ctx, orgID, "tx.signed", map[string]string{"tx_id": txID})
}

Some files were not shown because too many files have changed in this diff Show More