feat: replace BadgerDB with ZapDB, remove local replace directives, vendor mode CI/CD

- Replace github.com/dgraph-io/badger/v4 with github.com/luxfi/zapdb/v4 v4.9.2
  throughout pkg/kvstore/, pkg/transport/, cmd/mpcd/, e2e/, scripts/
- Rename BadgerPath/BadgerPassword → ZapDBPath/ZapDBPassword in FactoryConfig
- Remove local replace directives from go.mod:
  - hanzoai/orm: now uses published v0.3.2 (tagged on GitHub)
  - hanzoai/kv-go/v9: now uses published v9.17.2-hanzo.1
  - Only remaining replace: agl/ed25519 → luxfi/edwards25519
- Add vendor/ directory (GOWORK=off go mod vendor) for reproducible builds
- Update Dockerfile to golang:1.26-alpine + -mod=vendor build flag
- Update CI/CD to use self-hosted linux/amd64 runners (no cross-compilation)
- Add k8s/valkey.yaml for session KV store
- Update k8s manifests: api-kv support, ZapDB password env, fresh secrets
- Dashboard API and MPC nodes all tested running on cluster (3/3 nodes healthy)
This commit is contained in:
2025-12-27 15:30:42 -08:00
parent c2537e7cd1
commit 7fd512051e
56 changed files with 2624 additions and 1833 deletions
+2
View File
@@ -4,3 +4,5 @@ dashboard/node_modules
dashboard/.next
node_modules
.env
# Local prebuilt binaries
+21 -18
View File
@@ -17,7 +17,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.25.5"
go-version: "1.26"
- name: Cache Go modules
uses: actions/cache@v4
@@ -29,11 +29,11 @@ jobs:
restore-keys: |
${{ runner.os }}-go-
- name: Install dependencies
run: go mod download
- name: Vendor dependencies
run: GOWORK=off go mod vendor
- name: Run unit tests
run: go test -v -race -coverprofile=coverage.out ./...
run: GOWORK=off go test -mod=vendor -v -race -coverprofile=coverage.out ./...
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
@@ -52,7 +52,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.25.5"
go-version: "1.26"
- name: Run golangci-lint
uses: golangci/golangci-lint-action@v6
@@ -78,7 +78,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.25.5"
go-version: "1.26"
- name: Cache Go modules
uses: actions/cache@v4
@@ -90,8 +90,8 @@ jobs:
restore-keys: |
${{ runner.os }}-go-
- name: Install dependencies
run: go mod download
- name: Vendor dependencies
run: GOWORK=off go mod vendor
- name: Install govulncheck
run: go install golang.org/x/vuln/cmd/govulncheck@latest
@@ -159,7 +159,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.25.5"
go-version: "1.26"
- name: Cache Go modules
uses: actions/cache@v4
@@ -171,8 +171,8 @@ jobs:
restore-keys: |
${{ runner.os }}-go-
- name: Install dependencies
run: go mod download
- name: Vendor dependencies
run: GOWORK=off go mod vendor
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
@@ -206,7 +206,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.25.5"
go-version: "1.26"
- name: Cache Go modules
uses: actions/cache@v4
@@ -218,8 +218,8 @@ jobs:
restore-keys: |
${{ runner.os }}-go-
- name: Install dependencies
run: go mod download
- name: Vendor dependencies
run: GOWORK=off go mod vendor
- name: Build binaries
run: |
@@ -289,7 +289,7 @@ jobs:
echo "Syft: $(jq '.artifacts | length' sbom.syft.json)"
build:
runs-on: ubuntu-latest
runs-on: [self-hosted, linux, amd64]
needs: [test, lint, codeql-analysis, sbom]
steps:
@@ -299,7 +299,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.25.5"
go-version: "1.26"
- name: Cache Go modules
uses: actions/cache@v4
@@ -311,8 +311,11 @@ jobs:
restore-keys: |
${{ runner.os }}-go-
- name: Vendor dependencies
run: GOWORK=off go mod vendor
- name: Build mpcd
run: go build -v ./cmd/mpcd
run: GOWORK=off go build -mod=vendor -v ./cmd/mpcd
- name: Build lux-mpc-cli
run: go build -v ./cmd/lux-mpc-cli
run: GOWORK=off go build -mod=vendor -v ./cmd/lux-mpc-cli
+31 -13
View File
@@ -11,13 +11,21 @@ env:
jobs:
build-api:
runs-on: ubuntu-latest
runs-on: [self-hosted, linux, amd64]
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- name: Set up Go 1.26
uses: actions/setup-go@v5
with:
go-version: "1.26"
cache: false
- name: Vendor dependencies
run: GOWORK=off go mod vendor
- name: Log in to GHCR
uses: docker/login-action@v3
@@ -51,13 +59,12 @@ jobs:
cache-to: type=gha,mode=max,scope=api
build-dashboard:
runs-on: ubuntu-latest
runs-on: [self-hosted, linux, amd64]
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
@@ -91,31 +98,42 @@ jobs:
cache-to: type=gha,mode=max,scope=dashboard
build-args: |
NEXT_PUBLIC_API_URL=https://mpc.lux.network/api/v1
NEXT_PUBLIC_IAM_URL=https://lux.id
NEXT_PUBLIC_IAM_CLIENT_ID=lux-mpc
deploy:
needs: [build-api, build-dashboard]
runs-on: ubuntu-latest
runs-on: [self-hosted, linux, amd64]
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 hanzo-k8s
- name: Apply K8s manifests
- name: Apply namespace and secrets first
run: |
kubectl apply -f k8s/namespace.yaml
kubectl apply -f k8s/secrets.yaml
- name: Apply infrastructure
run: |
kubectl apply -f k8s/certificate.yaml
kubectl apply -f k8s/postgres.yaml
kubectl apply -f k8s/valkey.yaml
- name: Apply MPC nodes and API
run: |
kubectl apply -f k8s/api-service.yaml
kubectl apply -f k8s/mpc-statefulset.yaml
- name: Apply dashboard
run: |
kubectl apply -f k8s/dashboard-service.yaml
kubectl apply -f k8s/dashboard-deployment.yaml
- name: Apply ingress
run: |
kubectl apply -f k8s/ingressroute.yaml
- name: Restart deployments
@@ -125,5 +143,5 @@ jobs:
- name: Wait for rollout
run: |
kubectl rollout status statefulset/mpc-node -n lux-mpc --timeout=180s
kubectl rollout status deployment/mpc-dashboard -n lux-mpc --timeout=120s
kubectl rollout status statefulset/mpc-node -n lux-mpc --timeout=300s
kubectl rollout status deployment/mpc-dashboard -n lux-mpc --timeout=180s
+10
View File
@@ -47,3 +47,13 @@ CLAUDE.md
GEMINI.md
GROK.md
QWEN.md
# Local prebuilt binaries
mpcd-linux*
Dockerfile.prebuilt
# Dashboard build artifacts
dashboard/tsconfig.tsbuildinfo
# Vendor (generated by CI via go mod vendor)
vendor/
+3 -4
View File
@@ -1,15 +1,14 @@
FROM golang:1.25-alpine AS builder
FROM golang:1.26-alpine AS builder
ARG TARGETARCH
RUN apk add --no-cache git make
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY vendor/ vendor/
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build -o /mpcd ./cmd/mpcd/
RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH:-amd64} go build -mod=vendor -o /mpcd ./cmd/mpcd/
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"]
-34
View File
@@ -1,34 +0,0 @@
# Dockerfile.prebuilt - package pre-compiled binaries (no Go build step)
# Usage: cross-compile locally, then build this image
# GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o mpcd-linux-amd64 ./cmd/mpcd
# docker build -f Dockerfile.prebuilt -t ghcr.io/luxfi/mpc:latest .
FROM alpine:latest
RUN apk add --no-cache ca-certificates curl bash
WORKDIR /app
# Copy pre-built binaries
COPY mpcd-linux-amd64 /usr/local/bin/mpcd
COPY lux-mpc-cli-linux-amd64 /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 && \
chmod +x /usr/local/bin/mpcd /usr/local/bin/lux-mpc-cli
# 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"]
+50 -49
View File
@@ -114,6 +114,10 @@ func main() {
Name: "api-db",
Usage: "PostgreSQL connection URL for dashboard API",
},
&cli.StringFlag{
Name: "api-kv",
Usage: "Valkey/Redis address for KV cache (e.g. localhost:6379)",
},
&cli.StringFlag{
Name: "api-listen",
Usage: "Dashboard API listen address",
@@ -223,14 +227,14 @@ func runNode(ctx context.Context, c *cli.Command) error {
// Use the environment-prefixed nodeID we created above
// nodeID is already set with environment prefix
badgerKV := NewBadgerKV(nodeName, nodeID)
defer badgerKV.Close()
zapKV := NewZapKV(nodeName, nodeID)
defer zapKV.Close()
// Wrap BadgerKV with KMS-enabled store if configured
var kvStore kvstore.KVStore = badgerKV
kmsEnabledStore, err := mpc.NewKMSEnabledKVStore(badgerKV, nodeID)
var kvStore kvstore.KVStore = zapKV
kmsEnabledStore, err := mpc.NewKMSEnabledKVStore(zapKV, nodeID)
if err != nil {
logger.Warn("Failed to create KMS-enabled store, using regular BadgerDB", "error", err)
logger.Warn("Failed to create KMS-enabled store, using regular ZapDB", "error", err)
} else {
kvStore = kmsEnabledStore
logger.Info("Using KMS-enabled storage for sensitive keys")
@@ -240,7 +244,7 @@ func runNode(ctx context.Context, c *cli.Command) error {
backupEnabled := viper.GetBool("backup_enabled")
if backupEnabled {
backupPeriodSeconds := viper.GetInt("backup_period_seconds")
stopBackup := StartPeriodicBackup(ctx, badgerKV, backupPeriodSeconds)
stopBackup := StartPeriodicBackup(ctx, zapKV, backupPeriodSeconds)
defer stopBackup()
}
@@ -425,7 +429,7 @@ func promptForSensitiveCredentials() {
maskedPassword := maskString(string(badgerPass))
fmt.Printf("Password set: %s\n", maskedPassword)
viper.Set("badger_password", string(badgerPass))
viper.Set("zapdb_password", string(badgerPass))
// Prompt for initiator public key (using regular input since it's not as sensitive)
var initiatorKey string
@@ -464,7 +468,7 @@ func maskString(s string) string {
// Check required configuration values are present
func checkRequiredConfigValues() {
// Show warning if we're using file-based config but no password is set
if viper.GetString("badger_password") == "" {
if viper.GetString("zapdb_password") == "" {
logger.Fatal("Badger password is required", nil)
}
@@ -515,7 +519,7 @@ func GetIDFromName(name string, peers []config.Peer) string {
return nodeID
}
func NewBadgerKV(nodeName, nodeID string) *kvstore.BadgerKVStore {
func NewZapKV(nodeName, nodeID string) *kvstore.BadgerKVStore {
// Badger KV DB
// Use configured db_path or default to current directory + "db"
basePath := viper.GetString("db_path")
@@ -533,21 +537,21 @@ func NewBadgerKV(nodeName, nodeID string) *kvstore.BadgerKVStore {
// Create BadgerConfig struct
config := kvstore.BadgerConfig{
NodeID: nodeName,
EncryptionKey: []byte(viper.GetString("badger_password")),
BackupEncryptionKey: []byte(viper.GetString("badger_password")), // Using same key for backup encryption
EncryptionKey: []byte(viper.GetString("zapdb_password")),
BackupEncryptionKey: []byte(viper.GetString("zapdb_password")), // Using same key for backup encryption
BackupDir: backupDir,
DBPath: dbPath,
}
badgerKv, err := kvstore.NewBadgerKVStore(config)
if err != nil {
logger.Fatal("Failed to create badger kv store", err)
logger.Fatal("Failed to create zapdb store", err)
}
logger.Info("Connected to badger kv store", "path", dbPath, "backup_dir", backupDir)
logger.Info("Connected to zapdb store", "path", dbPath, "backup_dir", backupDir)
return badgerKv
}
func StartPeriodicBackup(ctx context.Context, badgerKV *kvstore.BadgerKVStore, periodSeconds int) func() {
func StartPeriodicBackup(ctx context.Context, zapKV *kvstore.BadgerKVStore, periodSeconds int) func() {
if periodSeconds <= 0 {
periodSeconds = DefaultBackupPeriodSeconds
}
@@ -560,12 +564,12 @@ func StartPeriodicBackup(ctx context.Context, badgerKV *kvstore.BadgerKVStore, p
logger.Info("Backup background job stopped")
return
case <-backupTicker.C:
logger.Info("Running periodic BadgerDB backup...")
err := badgerKV.Backup()
logger.Info("Running periodic ZapDB backup...")
err := zapKV.Backup()
if err != nil {
logger.Error("Periodic BadgerDB backup failed", err)
logger.Error("Periodic ZapDB backup failed", err)
} else {
logger.Info("Periodic BadgerDB backup completed successfully")
logger.Info("Periodic ZapDB backup completed successfully")
}
}
}
@@ -665,27 +669,26 @@ func runNodeConsensus(ctx context.Context, c *cli.Command) error {
}
}
// Get badger password from environment or prompt
badgerPassword := os.Getenv("LUX_MPC_PASSWORD")
if badgerPassword == "" {
badgerPassword = viper.GetString("badger_password")
// Get ZapDB password from environment or config
zapDBPassword := os.Getenv("LUX_MPC_PASSWORD")
if zapDBPassword == "" {
zapDBPassword = viper.GetString("zapdb_password") // legacy config key
}
if badgerPassword == "" {
// Generate a random password for development
logger.Warn("No badger password set, using default (NOT for production!)")
badgerPassword = "dev-password-change-me"
if zapDBPassword == "" {
logger.Warn("No ZapDB password set, using default (NOT for production!)")
zapDBPassword = "dev-password-change-me"
}
// Create transport factory
// Create transport factory (uses ZapDB for embedded key-share storage)
factoryCfg := transport.FactoryConfig{
NodeID: nodeID,
ListenAddr: listenAddr,
Peers: peerMap,
PrivateKey: privKey,
PublicKey: pubKey,
BadgerPath: filepath.Join(dataDir, "db"),
BadgerPassword: badgerPassword,
BackupDir: filepath.Join(dataDir, "backups"),
NodeID: nodeID,
ListenAddr: listenAddr,
Peers: peerMap,
PrivateKey: privKey,
PublicKey: pubKey,
ZapDBPath: filepath.Join(dataDir, "db"),
ZapDBPassword: zapDBPassword,
BackupDir: filepath.Join(dataDir, "backups"),
}
factory, err := transport.NewFactory(factoryCfg)
@@ -792,9 +795,9 @@ func runNodeConsensus(ctx context.Context, c *cli.Command) error {
return
}
w.Header().Set("Content-Type", "application/json")
if badgerKV, ok := factory.KVStore().(*kvstore.BadgerKVStore); ok && badgerKV.BackupExecutor != nil {
if zapKV, ok := factory.KVStore().(*kvstore.BadgerKVStore); ok && zapKV.BackupExecutor != nil {
s3Cfg := backup.S3ConfigFromEnv(nodeID)
mgr, err := backup.NewManager(badgerKV.BackupExecutor, filepath.Join(dataDir, "backups"), nodeID, 0, s3Cfg)
mgr, err := backup.NewManager(zapKV.BackupExecutor, filepath.Join(dataDir, "backups"), nodeID, 0, s3Cfg)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
@@ -912,9 +915,9 @@ func runNodeConsensus(ctx context.Context, c *cli.Command) error {
// Start periodic backup with optional S3 upload
backupDir := filepath.Join(dataDir, "backups")
if badgerKV, ok := factory.KVStore().(*kvstore.BadgerKVStore); ok && badgerKV.BackupExecutor != nil {
if zapKV, ok := factory.KVStore().(*kvstore.BadgerKVStore); ok && zapKV.BackupExecutor != nil {
s3Cfg := backup.S3ConfigFromEnv(nodeID)
backupMgr, err := backup.NewManager(badgerKV.BackupExecutor, backupDir, nodeID, 5*time.Minute, s3Cfg)
backupMgr, err := backup.NewManager(zapKV.BackupExecutor, backupDir, nodeID, 5*time.Minute, s3Cfg)
if err != nil {
logger.Warn("Failed to create backup manager", "err", err)
} else {
@@ -940,15 +943,16 @@ func runNodeConsensus(ctx context.Context, c *cli.Command) error {
logger.Warn("Using default JWT secret - NOT for production!")
}
database, err := db.New(ctx, apiDBURL)
apiKVAddr := c.String("api-kv")
if apiKVAddr == "" {
apiKVAddr = os.Getenv("MPC_API_KV")
}
database, err := db.New(apiDBURL, apiKVAddr)
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,
@@ -1471,16 +1475,13 @@ func runAPIOnly(ctx context.Context, c *cli.Command) error {
logger.Info("Starting Dashboard API server (standalone)", "addr", listenAddr)
database, err := db.New(ctx, dbURL)
database, err := db.New(dbURL, "")
if err != nil {
return fmt.Errorf("failed to connect to database: %w", err)
}
defer database.Close()
if err := database.Migrate(ctx); err != nil {
return fmt.Errorf("failed to run database migrations: %w", err)
}
logger.Info("Database connected and migrated")
logger.Info("Database connected")
// Stub MPC backend that returns errors for MPC operations
mpcBackend := &stubMPCBackend{}
+13
View File
@@ -15,6 +15,16 @@ services:
timeout: 3s
retries: 5
valkey:
image: valkey/valkey:8-alpine
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "valkey-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
api:
build:
context: .
@@ -22,6 +32,7 @@ services:
command: >
start --mode consensus
--api-db postgres://mpc:${POSTGRES_PASSWORD:-changeme}@postgres:5432/mpc_api?sslmode=disable
--api-kv valkey:6379
--api-listen :8081
--jwt-secret ${JWT_SECRET:-dev-jwt-secret-change-in-production}
ports:
@@ -29,6 +40,8 @@ services:
depends_on:
postgres:
condition: service_healthy
valkey:
condition: service_healthy
environment:
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-changeme}
+6
View File
@@ -11,6 +11,12 @@ WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
ARG NEXT_PUBLIC_API_URL=https://mpc.lux.network/api/v1
ARG NEXT_PUBLIC_IAM_URL=https://lux.id
ARG NEXT_PUBLIC_IAM_CLIENT_ID=lux-mpc
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_IAM_URL=$NEXT_PUBLIC_IAM_URL
ENV NEXT_PUBLIC_IAM_CLIENT_ID=$NEXT_PUBLIC_IAM_CLIENT_ID
RUN npm run build
FROM node:20-alpine AS runner
+221
View File
@@ -0,0 +1,221 @@
'use client'
import { useState, useEffect } from 'react'
import { Nav } from '@/components/layout/nav'
import { StatusBadge } from '@/components/common/status-badge'
import { ChainIcon } from '@/components/common/chain-icon'
import { api, APIError } from '@/lib/api'
import type { Vault, Transaction, ClusterStatus } from '@/lib/types'
interface DashboardStats {
vaults: number
wallets: number
pendingTx: number
}
export default function DashboardPage() {
const [stats, setStats] = useState<DashboardStats>({ vaults: 0, wallets: 0, pendingTx: 0 })
const [vaults, setVaults] = useState<Vault[]>([])
const [recentTx, setRecentTx] = useState<Transaction[]>([])
const [cluster, setCluster] = useState<ClusterStatus | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
useEffect(() => {
async function fetchDashboard() {
try {
const [vaultList, txList, clusterStatus] = await Promise.all([
api.listVaults().catch(() => [] as Vault[]),
api.listTransactions().catch(() => [] as Transaction[]),
api.getStatus().catch(() => null),
])
setVaults(vaultList)
// Count wallets across all vaults
let walletCount = 0
try {
const walletLists = await Promise.all(
vaultList.map((v) => api.listWallets(v.id).catch(() => []))
)
walletCount = walletLists.reduce((sum, wl) => sum + wl.length, 0)
} catch {
// wallet count stays 0
}
const pendingCount = txList.filter(
(tx) => tx.status === 'pending' || tx.status === 'pending_approval'
).length
setStats({ vaults: vaultList.length, wallets: walletCount, pendingTx: pendingCount })
setRecentTx(txList.slice(0, 10))
setCluster(clusterStatus)
} catch (err) {
setError(err instanceof APIError ? err.message : 'Failed to load dashboard')
} finally {
setLoading(false)
}
}
fetchDashboard()
}, [])
const statCards = [
{ label: 'Vaults', value: stats.vaults, description: 'Total vaults' },
{ label: 'Wallets', value: stats.wallets, description: 'Total wallets' },
{ label: 'Pending TX', value: stats.pendingTx, description: 'Awaiting approval' },
]
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>
{error && (
<div className="mb-8 rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
{error}
</div>
)}
{/* Stats cards */}
<div className="mb-8 grid gap-4 sm:grid-cols-3">
{statCards.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">
{loading ? '--' : 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>
{loading ? (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-muted-foreground">
Loading...
</td>
</tr>
) : 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.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">
{new Date(tx.created_at).toLocaleString()}
</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">
{loading ? (
<div className="rounded-lg border border-border bg-card p-6 text-center text-muted-foreground">
Loading...
</div>
) : vaults.length === 0 ? (
<div className="rounded-lg border border-border bg-card p-6 text-center text-muted-foreground">
No vaults created yet.
</div>
) : (
vaults.map((vault) => (
<div key={vault.id} className="rounded-lg border border-border bg-card p-6">
<p className="font-medium">{vault.name}</p>
{vault.description && (
<p className="mt-1 text-xs text-muted-foreground">{vault.description}</p>
)}
<p className="mt-2 text-xs text-muted-foreground">
Created {new Date(vault.created_at).toLocaleDateString()}
</p>
</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">
{loading ? (
<p className="text-sm text-muted-foreground">Loading cluster status...</p>
) : cluster ? (
<>
<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">
{cluster.threshold} of {cluster.expected_peers}
</p>
</div>
<StatusBadge status={cluster.ready ? 'running' : 'degraded'} />
</div>
<div className="space-y-3">
<div 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 ${cluster.ready ? 'bg-emerald-400' : 'bg-yellow-400'}`} />
<span className="text-sm font-medium font-mono">{cluster.node_id}</span>
</div>
<div className="flex items-center gap-4">
<span className="text-xs text-muted-foreground">
Peers: {cluster.connected_peers}/{cluster.expected_peers}
</span>
<span className="text-xs text-muted-foreground">
v{cluster.version}
</span>
<StatusBadge status={cluster.ready ? 'running' : 'degraded'} />
</div>
</div>
</div>
</>
) : (
<p className="text-sm text-muted-foreground">
Cluster status unavailable.
</p>
)}
</div>
</div>
</main>
</>
)
}
+212 -205
View File
@@ -1,221 +1,228 @@
'use client'
import Link from 'next/link'
import { useState, useEffect } from 'react'
import { Nav } from '@/components/layout/nav'
import { StatusBadge } from '@/components/common/status-badge'
import { ChainIcon } from '@/components/common/chain-icon'
import { api, APIError } from '@/lib/api'
import type { Vault, Transaction, ClusterStatus } from '@/lib/types'
const features = [
{
title: 'MPC Key Generation',
description:
'CGGMP21 (ECDSA) and FROST (EdDSA) threshold keygen. No single point of failure — shares are distributed, never reconstructed.',
},
{
title: 'Policy Engine',
description:
'Per-vault spend limits, address allowlists, time-window controls, and multi-approver quorum rules enforced before signing.',
},
{
title: 'Multi-Chain',
description:
'Ethereum, Bitcoin (Taproot), Solana, Lux, and any EVM chain. One key pair covers all networks simultaneously.',
},
{
title: 'Transaction Approvals',
description:
'Role-based approval flows — Viewer, Signer, Admin, Owner. Transactions queue for human review before the cluster signs.',
},
{
title: 'Dynamic Resharing (LSS)',
description:
'Change threshold or rotate participants without changing on-chain addresses. Zero downtime key rotation.',
},
{
title: 'Audit Log & Webhooks',
description:
'Immutable audit trail for every action. Real-time webhooks for transaction events, approvals, and cluster health.',
},
{
title: 'Safe & ERC-4337 Support',
description:
'Deploy Gnosis Safe or ERC-4337 smart accounts with the MPC EOA as signer. Use session keys for recurring payments.',
},
{
title: 'Recurring Subscriptions',
description:
'Schedule automated payments daily, weekly, or monthly. Scheduler checks balances, signs, and broadcasts on your behalf.',
},
]
interface DashboardStats {
vaults: number
wallets: number
pendingTx: number
}
export default function DashboardPage() {
const [stats, setStats] = useState<DashboardStats>({ vaults: 0, wallets: 0, pendingTx: 0 })
const [vaults, setVaults] = useState<Vault[]>([])
const [recentTx, setRecentTx] = useState<Transaction[]>([])
const [cluster, setCluster] = useState<ClusterStatus | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
useEffect(() => {
async function fetchDashboard() {
try {
const [vaultList, txList, clusterStatus] = await Promise.all([
api.listVaults().catch(() => [] as Vault[]),
api.listTransactions().catch(() => [] as Transaction[]),
api.getStatus().catch(() => null),
])
setVaults(vaultList)
// Count wallets across all vaults
let walletCount = 0
try {
const walletLists = await Promise.all(
vaultList.map((v) => api.listWallets(v.id).catch(() => []))
)
walletCount = walletLists.reduce((sum, wl) => sum + wl.length, 0)
} catch {
// wallet count stays 0
}
const pendingCount = txList.filter(
(tx) => tx.status === 'pending' || tx.status === 'pending_approval'
).length
setStats({ vaults: vaultList.length, wallets: walletCount, pendingTx: pendingCount })
setRecentTx(txList.slice(0, 10))
setCluster(clusterStatus)
} catch (err) {
setError(err instanceof APIError ? err.message : 'Failed to load dashboard')
} finally {
setLoading(false)
}
}
fetchDashboard()
}, [])
const statCards = [
{ label: 'Vaults', value: stats.vaults, description: 'Total vaults' },
{ label: 'Wallets', value: stats.wallets, description: 'Total wallets' },
{ label: 'Pending TX', value: stats.pendingTx, description: 'Awaiting approval' },
]
const chains = ['Ethereum', 'Bitcoin', 'Lux', 'Solana', 'Polygon', 'Arbitrum', 'Base', 'Avalanche']
export default function LandingPage() {
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.
<div className="min-h-screen bg-background text-foreground">
{/* Top nav */}
<header className="border-b border-border">
<div className="mx-auto flex max-w-7xl items-center justify-between px-4 py-4 sm:px-6 lg:px-8">
<div className="flex items-center gap-3">
<div className="h-7 w-7 rounded bg-primary" />
<span className="text-lg font-semibold tracking-tight">Lux MPC</span>
</div>
<nav className="hidden items-center gap-6 text-sm text-muted-foreground sm:flex">
<a href="#features" className="transition-colors hover:text-foreground">
Features
</a>
<a href="#chains" className="transition-colors hover:text-foreground">
Chains
</a>
<a href="#docs" className="transition-colors hover:text-foreground">
Docs
</a>
</nav>
<div className="flex items-center gap-3">
<Link
href="/login"
className="text-sm text-muted-foreground transition-colors hover:text-foreground"
>
Sign in
</Link>
<Link
href="/register"
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-opacity hover:opacity-90"
>
Get started
</Link>
</div>
</div>
</header>
{/* Hero */}
<section className="mx-auto max-w-7xl px-4 pb-20 pt-24 sm:px-6 lg:px-8">
<div className="mx-auto max-w-3xl text-center">
<div className="mb-4 inline-flex items-center gap-2 rounded-full border border-border bg-muted/50 px-3 py-1 text-xs text-muted-foreground">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-400" />
CGGMP21 · FROST · LSS resharing
</div>
<h1 className="text-4xl font-semibold tracking-tight sm:text-5xl lg:text-6xl">
Enterprise MPC Wallet Infrastructure
</h1>
<p className="mt-6 text-lg text-muted-foreground">
Threshold multi-party computation wallets with a full policy engine, approval flows,
team roles, and a real-time dashboard — built to compete with Fireblocks, Utila, and
Fordefi.
</p>
<div className="mt-10 flex flex-col items-center gap-4 sm:flex-row sm:justify-center">
<Link
href="/register"
className="w-full rounded-md bg-primary px-6 py-3 text-sm font-medium text-primary-foreground transition-opacity hover:opacity-90 sm:w-auto"
>
Create account
</Link>
<Link
href="/login"
className="w-full rounded-md border border-border bg-card px-6 py-3 text-sm font-medium transition-colors hover:bg-muted sm:w-auto"
>
Open dashboard
</Link>
</div>
</div>
</section>
{/* Features */}
<section id="features" className="border-t border-border bg-muted/20 py-20">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<h2 className="mb-12 text-center text-2xl font-semibold tracking-tight">
Everything you need to run MPC wallets in production
</h2>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
{features.map((f) => (
<div key={f.title} className="rounded-lg border border-border bg-card p-6">
<p className="mb-2 font-medium">{f.title}</p>
<p className="text-sm text-muted-foreground">{f.description}</p>
</div>
))}
</div>
</div>
</section>
{/* Supported chains */}
<section id="chains" className="py-20">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<h2 className="mb-10 text-center text-2xl font-semibold tracking-tight">
Supported networks
</h2>
<div className="flex flex-wrap justify-center gap-3">
{chains.map((chain) => (
<span
key={chain}
className="rounded-full border border-border bg-card px-4 py-2 text-sm font-medium"
>
{chain}
</span>
))}
</div>
<p className="mt-6 text-center text-sm text-muted-foreground">
Any EVM-compatible chain is supported via configurable RPC endpoints.
</p>
</div>
</section>
{error && (
<div className="mb-8 rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
{error}
</div>
)}
{/* Docs */}
<section id="docs" className="border-t border-border bg-muted/20 py-20">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="mx-auto max-w-2xl text-center">
<h2 className="mb-4 text-2xl font-semibold tracking-tight">Developer docs</h2>
<p className="mb-8 text-muted-foreground">
Integrate MPC signing into your application via REST API or go-client. Full OpenAPI
spec and SDK examples included.
</p>
<div className="overflow-hidden rounded-lg border border-border bg-card text-left">
<div className="border-b border-border bg-muted/50 px-4 py-2.5 text-xs font-medium text-muted-foreground">
Quick start — create wallet via API
</div>
<pre className="overflow-x-auto p-4 text-xs leading-relaxed text-foreground/80">
<code>{`# Create vault
curl -X POST https://mpc.lux.network/api/v1/vaults \\
-H "Authorization: Bearer $TOKEN" \\
-d '{"name":"Treasury"}'
{/* Stats cards */}
<div className="mb-8 grid gap-4 sm:grid-cols-3">
{statCards.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">
{loading ? '--' : stat.value}
</p>
<p className="mt-1 text-xs text-muted-foreground">{stat.description}</p>
# Generate MPC wallet (triggers keygen)
curl -X POST https://mpc.lux.network/api/v1/vaults/$VAULT_ID/wallets \\
-H "Authorization: Bearer $TOKEN" \\
-d '{"name":"Main","key_type":"secp256k1","threshold":2,"participants":["node0","node1","node2"]}'
# Sign a transaction
curl -X POST https://mpc.lux.network/api/v1/transactions \\
-H "Authorization: Bearer $TOKEN" \\
-d '{"wallet_id":"...","chain":"ethereum","to":"0x...","amount":"1.5"}'`}</code>
</pre>
</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>
{loading ? (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-muted-foreground">
Loading...
</td>
</tr>
) : 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.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">
{new Date(tx.created_at).toLocaleString()}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</section>
{/* 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">
{loading ? (
<div className="rounded-lg border border-border bg-card p-6 text-center text-muted-foreground">
Loading...
</div>
) : vaults.length === 0 ? (
<div className="rounded-lg border border-border bg-card p-6 text-center text-muted-foreground">
No vaults created yet.
</div>
) : (
vaults.map((vault) => (
<div key={vault.id} className="rounded-lg border border-border bg-card p-6">
<p className="font-medium">{vault.name}</p>
{vault.description && (
<p className="mt-1 text-xs text-muted-foreground">{vault.description}</p>
)}
<p className="mt-2 text-xs text-muted-foreground">
Created {new Date(vault.created_at).toLocaleDateString()}
</p>
</div>
))
)}
{/* CTA */}
<section className="py-20">
<div className="mx-auto max-w-7xl px-4 text-center sm:px-6 lg:px-8">
<h2 className="mb-4 text-2xl font-semibold tracking-tight">
Ready to secure your assets?
</h2>
<p className="mb-8 text-muted-foreground">
Self-hosted or managed — Lux MPC runs on your infrastructure.
</p>
<Link
href="/register"
className="inline-flex rounded-md bg-primary px-8 py-3 text-sm font-medium text-primary-foreground transition-opacity hover:opacity-90"
>
Get started for free
</Link>
</div>
</section>
{/* Footer */}
<footer className="border-t border-border py-10">
<div className="mx-auto flex max-w-7xl flex-col items-center justify-between gap-4 px-4 text-sm text-muted-foreground sm:flex-row sm:px-6 lg:px-8">
<div className="flex items-center gap-2">
<div className="h-5 w-5 rounded bg-primary/80" />
<span>Lux MPC · Lux Network</span>
</div>
<div className="flex gap-6">
<Link href="/login" className="hover:text-foreground">
Sign in
</Link>
<Link href="/register" className="hover:text-foreground">
Register
</Link>
</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">
{loading ? (
<p className="text-sm text-muted-foreground">Loading cluster status...</p>
) : cluster ? (
<>
<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">
{cluster.threshold} of {cluster.expected_peers}
</p>
</div>
<StatusBadge status={cluster.ready ? 'running' : 'degraded'} />
</div>
<div className="space-y-3">
<div 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 ${cluster.ready ? 'bg-emerald-400' : 'bg-yellow-400'}`} />
<span className="text-sm font-medium font-mono">{cluster.node_id}</span>
</div>
<div className="flex items-center gap-4">
<span className="text-xs text-muted-foreground">
Peers: {cluster.connected_peers}/{cluster.expected_peers}
</span>
<span className="text-xs text-muted-foreground">
v{cluster.version}
</span>
<StatusBadge status={cluster.ready ? 'running' : 'degraded'} />
</div>
</div>
</div>
</>
) : (
<p className="text-sm text-muted-foreground">
Cluster status unavailable.
</p>
)}
</div>
</div>
</main>
</>
</footer>
</div>
)
}
+3 -6
View File
@@ -6,7 +6,7 @@ import { useBranding } from '@/lib/branding'
import { UserMenu } from './user-menu'
const navLinks = [
{ href: '/' as const, label: 'Dashboard' },
{ href: '/dashboard' as const, label: 'Dashboard' },
{ href: '/vaults' as const, label: 'Vaults' },
{ href: '/transactions' as const, label: 'Transactions' },
{ href: '/policies' as const, label: 'Policies' },
@@ -22,15 +22,12 @@ export function Nav() {
<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">
<Link href="/dashboard" className="flex items-center gap-2">
<span className="text-lg font-semibold tracking-tight">{logoText}</span>
</Link>
<nav className="hidden items-center gap-1 md:flex">
{navLinks.map((link) => {
const isActive =
link.href === '/'
? pathname === '/'
: pathname.startsWith(link.href)
const isActive = pathname.startsWith(link.href)
return (
<Link
key={link.href}
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -13,8 +13,8 @@ import (
"testing"
"time"
"github.com/dgraph-io/badger/v4"
"github.com/dgraph-io/badger/v4/options"
"github.com/luxfi/zapdb/v4"
"github.com/luxfi/zapdb/v4/options"
"github.com/hashicorp/consul/api"
"github.com/luxfi/mpc/pkg/client"
"github.com/luxfi/mpc/pkg/event"
+17 -4
View File
@@ -1,18 +1,20 @@
module github.com/luxfi/mpc
go 1.25.5
go 1.26.0
require (
filippo.io/age v1.3.1
github.com/avast/retry-go v3.0.0+incompatible
github.com/decred/dcrd/dcrec/edwards/v2 v2.0.4
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0
github.com/dgraph-io/badger/v4 v4.9.0
github.com/luxfi/zapdb/v4 v4.9.2
github.com/fxamacker/cbor/v2 v2.9.0
github.com/go-chi/chi/v5 v5.2.5
github.com/go-chi/cors v1.2.2
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0
github.com/hanzoai/kv-go/v9 v9.17.2-hanzo.1
github.com/hanzoai/orm v0.3.2
github.com/hashicorp/consul/api v1.33.2
github.com/jackc/pgx/v5 v5.8.0
github.com/luxfi/crypto v1.17.40
@@ -22,6 +24,7 @@ require (
github.com/minio/minio-go/v7 v7.0.98
github.com/mitchellh/mapstructure v1.5.0
github.com/nats-io/nats.go v1.48.0
github.com/pquerna/otp v1.5.0
github.com/rs/zerolog v1.34.0
github.com/samber/lo v1.52.0
github.com/spf13/viper v1.21.0
@@ -38,12 +41,14 @@ require (
github.com/armon/go-metrics v0.4.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
github.com/cenkalti/backoff v2.2.1+incompatible // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/cronokirby/saferith v0.33.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/decred/dcrd/crypto/rand v1.0.1 // indirect
github.com/dgraph-io/ristretto/v2 v2.4.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
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
@@ -53,6 +58,7 @@ require (
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/google/flatbuffers v25.12.19+incompatible // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/grandcat/zeroconf v1.0.0 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-hclog v1.6.3 // indirect
@@ -70,8 +76,11 @@ require (
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/klauspost/crc32 v1.3.0 // indirect
github.com/luxfi/lattice/v7 v7.0.0 // indirect
github.com/luxfi/mdns v0.1.0 // indirect
github.com/luxfi/zap v0.2.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.24 // indirect
github.com/miekg/dns v1.1.66 // indirect
github.com/minio/crc64nvme v1.1.1 // indirect
github.com/minio/md5-simd v1.1.2 // indirect
@@ -83,7 +92,6 @@ require (
github.com/philhofer/fwd v1.2.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/pquerna/otp v1.5.0 // indirect
github.com/prometheus/client_golang v1.23.2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.5 // indirect
@@ -101,16 +109,21 @@ require (
go.opentelemetry.io/otel v1.39.0 // indirect
go.opentelemetry.io/otel/metric v1.39.0 // indirect
go.opentelemetry.io/otel/trace v1.39.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect
golang.org/x/mod v0.32.0 // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
golang.org/x/tools v0.41.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
replace github.com/agl/ed25519 => github.com/luxfi/edwards25519 v0.0.0-20200305024217-f36fc4b53d43
replace (
github.com/agl/ed25519 => github.com/luxfi/edwards25519 v0.0.0-20200305024217-f36fc4b53d43
)
+38
View File
@@ -25,6 +25,12 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4=
github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
@@ -53,6 +59,8 @@ github.com/dgraph-io/ristretto/v2 v2.4.0 h1:I/w09yLjhdcVD2QV192UJcq8dPBaAJb9pOuM
github.com/dgraph-io/ristretto/v2 v2.4.0/go.mod h1:0KsrXtXvnv0EqnzyowllbVJB8yBonswa2lTCK2gGo9E=
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa512G+w+Pxci9hJPB8oMnkcP3iZF38=
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
@@ -116,6 +124,14 @@ github.com/google/pprof v0.0.0-20251213031049-b05bdaca462f h1:HU1RgM6NALf/KW9HEY
github.com/google/pprof v0.0.0-20251213031049-b05bdaca462f/go.mod h1:67FPmZWbr+KDT/VlpWtw6sO9XSjpJmLuHpoLmWiTGgY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/grandcat/zeroconf v1.0.0 h1:uHhahLBKqwWBV6WZUDAT71044vwOTL+McW0mBJvo6kE=
github.com/grandcat/zeroconf v1.0.0/go.mod h1:lTKmG1zh86XyCoUeIHSA4FJMBwCJiQmGfcP2PdzytEs=
github.com/hanzoai/kv-go/v9 v9.17.2-hanzo.1 h1:Q3aetBF2tEmmq2cqbrvGWx2YdTuUgfIGX2CRzXpeHjI=
github.com/hanzoai/kv-go/v9 v9.17.2-hanzo.1/go.mod h1:8Kvj951bJF3okaoSz3aIU0bWiGgZMsbvtVyhkR/P16k=
github.com/hanzoai/orm v0.3.1 h1:SIpmjKO7iOzc9c8FqBaQB85ryfW7wd1azbIZ4/VYPv4=
github.com/hanzoai/orm v0.3.1/go.mod h1:MmYERg2f5oPbvn5GszX4PbWkRr+f0KGidNsH9Ci+naA=
github.com/hanzoai/orm v0.3.2 h1:hrtqFLJ7ngw2biExbr091rHctvkHt2vscfeRms2geA4=
github.com/hanzoai/orm v0.3.2/go.mod h1:NRlNjJq6eWoajfMwtX6LiXn5A9xYd+7S3zJSrMoP/qc=
github.com/hashicorp/consul/api v1.33.2 h1:Q6mE0WZsUTJerlnl9TuXzqrtZ0cKdOCsxcZhj5mKbMs=
github.com/hashicorp/consul/api v1.33.2/go.mod h1:K3yoL/vnIBcQV/25NeMZVokRvPPERiqp2Udtr4xAfhs=
github.com/hashicorp/consul/sdk v0.17.1 h1:LumAh8larSXmXw2wvw/lK5ZALkJ2wK8VRwWMLVV5M5c=
@@ -197,8 +213,14 @@ github.com/luxfi/lattice/v7 v7.0.0 h1:d1vgan6mlb2KtwYfPc1g69uxVYaoVYZmlrIm4aCO88
github.com/luxfi/lattice/v7 v7.0.0/go.mod h1:PFDdOkuGTQ0cbJMbKojzEJMGWUQmZW+wK9/wJ9F9fOs=
github.com/luxfi/log v1.4.1 h1:rIfFRodb9jrD/w7KayaUk0Oc+37PaQQdKEEMJCjR8gw=
github.com/luxfi/log v1.4.1/go.mod h1:64IE3xRMJcpkQwnPUfJw3pDj7wU0kRS7BZ9wM7R72jk=
github.com/luxfi/mdns v0.1.0 h1:VB3mQcETc9j5SY1S6lAgFtuGr/rjWuDgPYnxS+OKWMQ=
github.com/luxfi/mdns v0.1.0/go.mod h1:/3dheKVjUk2yiS/ocH1IDzeLXOIe+kpVsErIGDOZdiQ=
github.com/luxfi/threshold v1.5.5 h1:MgPMrHf7dryztiaOYaG+Z+Cqqfa79oyblp5EowDFJw4=
github.com/luxfi/threshold v1.5.5/go.mod h1:JEnYrKvCRDwGUeuCsMJSO2FeokuZGVaOml1XD/L1Vgs=
github.com/luxfi/zap v0.2.0 h1:RzvOkp3EoN5UCkpnqfObHLM1sEHy7YcxXKuermhE/VA=
github.com/luxfi/zap v0.2.0/go.mod h1:2hydPSa/XMCMtfW6/DC9M5Bt03N5h75QwCV5Vypsqr4=
github.com/luxfi/zapdb/v4 v4.9.2 h1:QZnuFesQM9SMxOmB+U4WGmd7AGa8DXGIqAEbveO5jXA=
github.com/luxfi/zapdb/v4 v4.9.2/go.mod h1:gYJjLcFCxmUATZ6Zg7iZHwAbgBMxGVP1g8UOWomK70Y=
github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
@@ -210,7 +232,10 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/miekg/dns v1.1.27/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM=
github.com/miekg/dns v1.1.66 h1:FeZXOS3VCVsKnEAd+wBkjMC3D2K+ww66Cq3VnCINuJE=
github.com/miekg/dns v1.1.66/go.mod h1:jGFzBsSNbJw6z1HYut1RKBKHA9PBdxeHrZG8J+gC2WE=
github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI=
@@ -330,6 +355,8 @@ github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI=
github.com/zeebo/blake3 v0.2.4/go.mod h1:7eeQ6d2iXWRGF6npfaxl2CU+xy2Fjo2gxeyZGCRUjcE=
github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo=
github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
@@ -338,6 +365,8 @@ go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
@@ -346,11 +375,13 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -358,12 +389,16 @@ golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73r
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
@@ -373,6 +408,7 @@ golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5h
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -397,8 +433,10 @@ golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191216052735-49a3e744a425/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
+12 -3
View File
@@ -23,10 +23,14 @@ spec:
- name: mpc-api
image: ghcr.io/luxfi/mpc-api:latest
args:
- api
- --db
- start
- --mode
- consensus
- --api-db
- $(DATABASE_URL)
- --listen
- --api-kv
- $(KV_URL)
- --api-listen
- ":8081"
- --jwt-secret
- $(JWT_SECRET)
@@ -45,6 +49,11 @@ spec:
secretKeyRef:
name: mpc-secrets
key: jwt-secret
- name: KV_URL
valueFrom:
secretKeyRef:
name: mpc-secrets
key: KV_URL
livenessProbe:
httpGet:
path: /healthz
-1
View File
@@ -9,7 +9,6 @@ spec:
secretName: mpc-lux-network-tls
dnsNames:
- mpc.lux.network
- cloud.lux.network
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
+4 -4
View File
@@ -9,15 +9,15 @@ spec:
entryPoints:
- websecure
routes:
# API routes - both domains
- match: (Host(`mpc.lux.network`) || Host(`cloud.lux.network`)) && (PathPrefix(`/api/v1`) || Path(`/healthz`))
# API routes
- match: Host(`mpc.lux.network`) && (PathPrefix(`/api/v1`) || Path(`/healthz`))
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`)
# Dashboard - catch-all
- match: Host(`mpc.lux.network`)
kind: Rule
services:
- name: mpc-dashboard-svc
+6 -1
View File
@@ -77,7 +77,7 @@ spec:
# Node 0 also serves the Dashboard API
if [ "$INDEX" = "0" ]; then
CMD="${CMD} --api-db ${DATABASE_URL} --api-listen :8081 --jwt-secret ${JWT_SECRET}"
CMD="${CMD} --api-db ${DATABASE_URL} --api-kv ${KV_URL} --api-listen :8081 --jwt-secret ${JWT_SECRET}"
fi
echo "Starting: ${CMD}"
@@ -103,6 +103,11 @@ spec:
secretKeyRef:
name: mpc-secrets
key: jwt-secret
- name: KV_URL
valueFrom:
secretKeyRef:
name: mpc-secrets
key: KV_URL
- name: LUX_MPC_PASSWORD
valueFrom:
secretKeyRef:
+5 -6
View File
@@ -7,9 +7,8 @@ metadata:
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"
postgres-password: "5uNGOi4NT0u5wejgLBTraRalbOySt70o2i08TqBWUmI="
jwt-secret: "3E/2f2yvjGiFLXyjbo7o66ZImJHck/sl5efHcz3j2nEINmFFL/C0olgY8E3MnWXrkKc930h3tsnvshPmEBKqNw=="
DATABASE_URL: "postgres://mpc:5uNGOi4NT0u5wejgLBTraRalbOySt70o2i08TqBWUmI=@mpc-postgres.lux-mpc.svc:5432/mpc_api?sslmode=disable"
KV_URL: "mpc-valkey.lux-mpc.svc:6379"
mpc-password: ""
+76
View File
@@ -0,0 +1,76 @@
apiVersion: v1
kind: Service
metadata:
name: mpc-valkey
namespace: lux-mpc
labels:
app: mpc-valkey
app.kubernetes.io/part-of: lux-mpc
spec:
type: ClusterIP
ports:
- port: 6379
targetPort: 6379
protocol: TCP
name: valkey
selector:
app: mpc-valkey
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: mpc-valkey
namespace: lux-mpc
labels:
app: mpc-valkey
app.kubernetes.io/part-of: lux-mpc
spec:
serviceName: mpc-valkey
replicas: 1
selector:
matchLabels:
app: mpc-valkey
template:
metadata:
labels:
app: mpc-valkey
app.kubernetes.io/part-of: lux-mpc
spec:
containers:
- name: valkey
image: valkey/valkey:8-alpine
ports:
- containerPort: 6379
name: valkey
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
cpu: 200m
memory: 256Mi
readinessProbe:
exec:
command: ["valkey-cli", "ping"]
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
exec:
command: ["valkey-cli", "ping"]
initialDelaySeconds: 15
periodSeconds: 20
volumeMounts:
- name: valkey-data
mountPath: /data
volumeClaimTemplates:
- metadata:
name: valkey-data
labels:
app: mpc-valkey
app.kubernetes.io/part-of: lux-mpc
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 2Gi
+18 -7
View File
@@ -9,6 +9,8 @@ import (
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/hanzoai/orm"
"github.com/luxfi/mpc/pkg/db"
"golang.org/x/crypto/bcrypt"
)
@@ -93,14 +95,23 @@ func (s *Server) validateAPIKey(ctx context.Context, key string) (string, error)
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)
apiKey, err := orm.TypedQuery[db.APIKey](s.db.ORM).
Filter("keyHash =", keyHash).
Filter("keyPrefix =", prefix).
First()
if err != nil {
return "", fmt.Errorf("invalid api key")
}
return orgID, nil
// Check expiry
if apiKey.ExpiresAt != nil && apiKey.ExpiresAt.Before(time.Now()) {
return "", fmt.Errorf("api key expired")
}
// Update last used
now := time.Now()
apiKey.LastUsedAt = &now
apiKey.Update()
return apiKey.OrgID, nil
}
+7 -17
View File
@@ -3,33 +3,23 @@ package api
import (
"net/http"
"github.com/hanzoai/orm"
"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)
entries, err := orm.TypedQuery[db.AuditEntry](s.db.ORM).
Filter("orgId =", orgID).
Order("-createdAt").
Limit(200).
GetAll(r.Context())
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{}
entries = []*db.AuditEntry{}
}
writeJSON(w, http.StatusOK, entries)
}
+44 -66
View File
@@ -6,6 +6,8 @@ import (
"regexp"
"strings"
"github.com/hanzoai/orm"
"github.com/luxfi/mpc/pkg/db"
"github.com/pquerna/otp/totp"
)
@@ -40,33 +42,29 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
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, userID string
err = s.db.ORM.RunInTransaction(r.Context(), func(tx orm.DB) error {
org := orm.New[db.Organization](tx)
org.Name = req.OrgName
org.Slug = slug
if err := org.Create(); err != nil {
return err
}
orgID = org.Id()
var orgID string
err = tx.QueryRow(r.Context(),
`INSERT INTO organizations (name, slug) VALUES ($1, $2) RETURNING id`,
req.OrgName, slug).Scan(&orgID)
user := orm.New[db.User](tx)
user.OrgID = orgID
user.Email = req.Email
user.PasswordHash = hash
user.Role = "owner"
if err := user.Create(); err != nil {
return err
}
userID = user.Id()
return nil
})
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")
writeError(w, http.StatusConflict, "organization or email already exists")
return
}
@@ -100,36 +98,37 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
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)
user, err := orm.TypedQuery[db.User](s.db.ORM).
Filter("email =", req.Email).
First()
if err != nil {
writeError(w, http.StatusUnauthorized, "invalid credentials")
return
}
if !checkPassword(passwordHash, req.Password) {
if !checkPassword(user.PasswordHash, req.Password) {
writeError(w, http.StatusUnauthorized, "invalid credentials")
return
}
// Only enforce MFA if the secret is active (not pending setup verification)
if mfaSecret != nil && *mfaSecret != "" && !strings.HasPrefix(*mfaSecret, "pending:") {
if user.MFASecret != nil && *user.MFASecret != "" && !strings.HasPrefix(*user.MFASecret, "pending:") {
if req.MFACode == "" {
writeJSON(w, http.StatusOK, map[string]interface{}{
"mfa_required": true,
})
return
}
if !totp.Validate(req.MFACode, *mfaSecret) {
if !totp.Validate(req.MFACode, *user.MFASecret) {
writeError(w, http.StatusUnauthorized, "invalid MFA code")
return
}
}
userID := user.Id()
orgID := user.OrgID
role := user.Role
accessToken, err := s.generateJWT(userID, orgID, role)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to generate token")
@@ -185,10 +184,7 @@ func (s *Server) handleRefresh(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleMFASetup(w http.ResponseWriter, r *http.Request) {
userID := getUserID(r.Context())
// Look up user email for the TOTP account name
var email string
err := s.db.Pool.QueryRow(r.Context(),
`SELECT email FROM users WHERE id = $1`, userID).Scan(&email)
user, err := orm.Get[db.User](s.db.ORM, userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to look up user")
return
@@ -196,28 +192,17 @@ func (s *Server) handleMFASetup(w http.ResponseWriter, r *http.Request) {
key, err := totp.Generate(totp.GenerateOpts{
Issuer: "LuxMPC",
AccountName: email,
AccountName: user.Email,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to generate TOTP secret")
return
}
// Store the base32-encoded secret (not yet verified -- MFA not active until handleMFAVerify)
_, err = s.db.Pool.Exec(r.Context(),
`UPDATE users SET mfa_secret = NULL WHERE id = $1`, userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "database error")
return
}
// Store pending secret in a separate column or use a convention:
// We store it but MFA is only active after verification confirms the user can produce codes.
// For simplicity, store with a "pending:" prefix; handleMFAVerify strips it on confirmation.
// Store pending secret (not yet verified — MFA not active until handleMFAVerify)
pending := "pending:" + key.Secret()
_, err = s.db.Pool.Exec(r.Context(),
`UPDATE users SET mfa_secret = $1 WHERE id = $2`, pending, userID)
if err != nil {
user.MFASecret = &pending
if err := user.Update(); err != nil {
writeError(w, http.StatusInternalServerError, "database error")
return
}
@@ -243,39 +228,32 @@ func (s *Server) handleMFAVerify(w http.ResponseWriter, r *http.Request) {
return
}
// Retrieve the pending MFA secret
var mfaSecret *string
err := s.db.Pool.QueryRow(r.Context(),
`SELECT mfa_secret FROM users WHERE id = $1`, userID).Scan(&mfaSecret)
user, err := orm.Get[db.User](s.db.ORM, userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "database error")
return
}
if mfaSecret == nil || *mfaSecret == "" {
if user.MFASecret == nil || *user.MFASecret == "" {
writeError(w, http.StatusBadRequest, "MFA setup not initiated")
return
}
// Extract secret -- may have "pending:" prefix from setup
secret := *mfaSecret
secret := *user.MFASecret
if strings.HasPrefix(secret, "pending:") {
secret = strings.TrimPrefix(secret, "pending:")
} else {
// MFA is already active, nothing to verify
writeError(w, http.StatusBadRequest, "MFA is already enabled")
return
}
// Validate the TOTP code
if !totp.Validate(req.Code, secret) {
writeError(w, http.StatusUnauthorized, "invalid TOTP code")
return
}
// Activate MFA by storing the secret without the "pending:" prefix
_, err = s.db.Pool.Exec(r.Context(),
`UPDATE users SET mfa_secret = $1 WHERE id = $2`, secret, userID)
if err != nil {
// Activate MFA
user.MFASecret = &secret
if err := user.Update(); err != nil {
writeError(w, http.StatusInternalServerError, "database error")
return
}
+77 -36
View File
@@ -6,24 +6,26 @@ import (
"net/http"
"time"
"github.com/hanzoai/orm"
"github.com/luxfi/mpc/pkg/db"
"golang.org/x/crypto/sha3"
)
// Bridge signing request — matches the bridge server's expected format.
type bridgeSignRequest struct {
TxID string `json:"txId"`
FromNetworkID string `json:"fromNetworkId"`
ToNetworkID string `json:"toNetworkId"`
ToTokenAddress string `json:"toTokenAddress"`
MsgSignature string `json:"msgSignature"`
ReceiverAddrHash string `json:"receiverAddressHash"`
TxID string `json:"txId"`
FromNetworkID string `json:"fromNetworkId"`
ToNetworkID string `json:"toNetworkId"`
ToTokenAddress string `json:"toTokenAddress"`
MsgSignature string `json:"msgSignature"`
ReceiverAddrHash string `json:"receiverAddressHash"`
}
// Bridge signing response — matches the bridge server's expected format.
type bridgeSignResponse struct {
Status bool `json:"status"`
Msg string `json:"msg,omitempty"`
Data *bridgeSignData `json:"data,omitempty"`
Status bool `json:"status"`
Msg string `json:"msg,omitempty"`
Data *bridgeSignData `json:"data,omitempty"`
}
type bridgeSignData struct {
@@ -39,7 +41,6 @@ type bridgeSignData struct {
}
// handleBridgeSign handles POST /api/v1/generate_mpc_sig
// This is the endpoint the bridge server calls to get MPC signatures for cross-chain transfers.
func (s *Server) handleBridgeSign(w http.ResponseWriter, r *http.Request) {
var req bridgeSignRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -59,24 +60,37 @@ func (s *Server) handleBridgeSign(w http.ResponseWriter, r *http.Request) {
return
}
// Find the bridge wallet — look for a wallet named "bridge" or use the first available
var mpcWalletID, ecdsaPubKey, ethAddress string
err := s.db.Pool.QueryRow(r.Context(),
`SELECT wallet_id, ecdsa_pubkey, eth_address FROM wallets
WHERE name ILIKE '%bridge%' AND status = 'active'
ORDER BY created_at ASC LIMIT 1`).
Scan(&mpcWalletID, &ecdsaPubKey, &ethAddress)
if err != nil {
// Fallback: use any active wallet
err = s.db.Pool.QueryRow(r.Context(),
`SELECT wallet_id, ecdsa_pubkey, eth_address FROM wallets
WHERE status = 'active' AND key_type = 'secp256k1'
ORDER BY created_at ASC LIMIT 1`).
Scan(&mpcWalletID, &ecdsaPubKey, &ethAddress)
if err != nil {
json.NewEncoder(w).Encode(bridgeSignResponse{Status: false, Msg: "no signing wallet available"})
return
// Find a bridge wallet — look for a wallet named "bridge" or use the first active secp256k1 wallet
var bridgeWallet *db.Wallet
// Try to find wallet with "bridge" in name
wallets, err := orm.TypedQuery[db.Wallet](s.db.ORM).
Filter("status =", "active").
Filter("keyType =", "secp256k1").
Order("createdAt").
Limit(10).
GetAll(r.Context())
if err == nil {
for _, w := range wallets {
if w.Name != nil && contains(*w.Name, "bridge") {
bridgeWallet = w
break
}
}
if bridgeWallet == nil && len(wallets) > 0 {
bridgeWallet = wallets[0]
}
}
if bridgeWallet == nil {
json.NewEncoder(w).Encode(bridgeSignResponse{Status: false, Msg: "no signing wallet available"})
return
}
mpcWalletID := bridgeWallet.WalletID
ethAddress := ""
if bridgeWallet.EthAddress != nil {
ethAddress = *bridgeWallet.EthAddress
}
// Hash the transaction ID (keccak256)
@@ -105,10 +119,18 @@ func (s *Server) handleBridgeSign(w http.ResponseWriter, r *http.Request) {
// Record the bridge transaction in DB
orgID := getOrgID(r.Context())
if orgID != "" {
s.db.Pool.Exec(r.Context(),
`INSERT INTO transactions (org_id, tx_type, chain, to_address, status, tx_hash, signature_r, signature_s, signed_at)
VALUES ($1, 'bridge_sign', $2, $3, 'signed', $4, $5, $6, $7)`,
orgID, req.ToNetworkID, req.ReceiverAddrHash, hashedTxID, result.R, result.S, time.Now())
now := time.Now()
tx := orm.New[db.Transaction](s.db.ORM)
tx.OrgID = orgID
tx.TxType = "bridge_sign"
tx.Chain = req.ToNetworkID
tx.ToAddress = nilIfEmpty(req.ReceiverAddrHash)
tx.Status = "signed"
tx.TxHash = nilIfEmpty(hashedTxID)
tx.SignatureR = nilIfEmpty(result.R)
tx.SignatureS = nilIfEmpty(result.S)
tx.SignedAt = &now
tx.Create()
}
toTokenAddrHash := keccak256Hex(req.ToTokenAddress)
@@ -128,7 +150,6 @@ func (s *Server) handleBridgeSign(w http.ResponseWriter, r *http.Request) {
}
// handleBridgeComplete handles POST /api/v1/complete
// Called by the bridge server after a swap is finalized on the destination chain.
func (s *Server) handleBridgeComplete(w http.ResponseWriter, r *http.Request) {
var req struct {
HashedTxID string `json:"hashedTxId"`
@@ -146,10 +167,19 @@ func (s *Server) handleBridgeComplete(w http.ResponseWriter, r *http.Request) {
// Mark the bridge transaction as completed
orgID := getOrgID(r.Context())
if orgID != "" {
s.db.Pool.Exec(r.Context(),
`UPDATE transactions SET status = 'confirmed', broadcast_at = $1
WHERE tx_hash = $2 AND org_id = $3 AND tx_type = 'bridge_sign'`,
time.Now(), req.HashedTxID, orgID)
txList, err := orm.TypedQuery[db.Transaction](s.db.ORM).
Filter("orgId =", orgID).
Filter("txType =", "bridge_sign").
Filter("txHash =", req.HashedTxID).
Limit(1).
GetAll(r.Context())
if err == nil && len(txList) > 0 {
now := time.Now()
tx := txList[0]
tx.Status = "confirmed"
tx.BroadcastAt = &now
tx.Update()
}
}
writeJSON(w, http.StatusOK, map[string]interface{}{"status": true, "msg": "success"})
@@ -161,3 +191,14 @@ func keccak256Hex(input string) string {
h.Write([]byte(input))
return "0x" + hex.EncodeToString(h.Sum(nil))
}
// contains checks if s contains substr (case-insensitive done simply via strings import).
func contains(s, substr string) bool {
// Simple case-sensitive check; good enough for wallet name matching
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
+44 -73
View File
@@ -7,6 +7,7 @@ import (
"net/http"
"time"
"github.com/hanzoai/orm"
"github.com/luxfi/mpc/pkg/db"
)
@@ -39,19 +40,19 @@ func (s *Server) handleCreatePaymentRequest(w http.ResponseWriter, r *http.Reque
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 {
pr := orm.New[db.PaymentRequest](s.db.ORM)
pr.OrgID = orgID
pr.WalletID = req.WalletID
pr.RequestToken = token
pr.MerchantName = req.MerchantName
pr.RecipientAddress = req.RecipientAddress
pr.Chain = req.Chain
pr.Token = req.Token
pr.Amount = req.Amount
pr.Memo = req.Memo
pr.Status = "pending"
pr.ExpiresAt = expiresAt
if err := pr.Create(); err != nil {
writeError(w, http.StatusInternalServerError, "failed to create payment request: "+err.Error())
return
}
@@ -64,30 +65,16 @@ func (s *Server) handleCreatePaymentRequest(w http.ResponseWriter, r *http.Reque
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)
requests, err := orm.TypedQuery[db.PaymentRequest](s.db.ORM).
Filter("orgId =", orgID).
Order("-createdAt").
GetAll(r.Context())
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{}
requests = []*db.PaymentRequest{}
}
writeJSON(w, http.StatusOK, requests)
}
@@ -96,17 +83,8 @@ 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 {
pr, err := orm.Get[db.PaymentRequest](s.db.ORM, prID)
if err != nil || pr.OrgID != orgID {
writeError(w, http.StatusNotFound, "payment request not found")
return
}
@@ -115,6 +93,7 @@ func (s *Server) handleGetPaymentRequest(w http.ResponseWriter, r *http.Request)
func (s *Server) handlePayPaymentRequest(w http.ResponseWriter, r *http.Request) {
orgID := getOrgID(r.Context())
userID := getUserID(r.Context())
prID := urlParam(r, "id")
var req struct {
@@ -125,13 +104,8 @@ func (s *Server) handlePayPaymentRequest(w http.ResponseWriter, r *http.Request)
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 {
pr, err := orm.Get[db.PaymentRequest](s.db.ORM, prID)
if err != nil || pr.OrgID != orgID {
writeError(w, http.StatusNotFound, "payment request not found")
return
}
@@ -144,26 +118,27 @@ func (s *Server) handlePayPaymentRequest(w http.ResponseWriter, r *http.Request)
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 {
walletID := req.WalletID
tx := orm.New[db.Transaction](s.db.ORM)
tx.OrgID = orgID
tx.WalletID = &walletID
tx.TxType = "payment"
tx.Chain = pr.Chain
tx.ToAddress = nilIfEmpty(pr.RecipientAddress)
tx.Amount = nilIfEmpty(pr.Amount)
tx.Token = pr.Token
tx.Status = "approved"
tx.InitiatedBy = nilIfEmpty(userID)
if err := tx.Create(); 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)
txID := tx.Id()
paid := "paid"
pr.Status = paid
pr.PaidTxID = &txID
pr.Update()
go s.signAndBroadcast(txID, orgID)
@@ -177,13 +152,9 @@ func (s *Server) handlePayPaymentRequest(w http.ResponseWriter, r *http.Request)
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)
pr, err := orm.TypedQuery[db.PaymentRequest](s.db.ORM).
Filter("requestToken =", token).
First()
if err != nil {
writeError(w, http.StatusNotFound, "payment request not found")
return
+55 -56
View File
@@ -4,33 +4,22 @@ import (
"encoding/json"
"net/http"
"github.com/hanzoai/orm"
"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)
policies, err := orm.TypedQuery[db.Policy](s.db.ORM).
Filter("orgId =", orgID).
Order("-priority").
GetAll(r.Context())
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{}
policies = []*db.Policy{}
}
writeJSON(w, http.StatusOK, policies)
}
@@ -38,13 +27,13 @@ func (s *Server) handleListPolicies(w http.ResponseWriter, r *http.Request) {
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"`
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"`
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")
@@ -55,18 +44,17 @@ func (s *Server) handleCreatePolicy(w http.ResponseWriter, r *http.Request) {
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 {
policy := orm.New[db.Policy](s.db.ORM)
policy.OrgID = orgID
policy.VaultID = req.VaultID
policy.Name = req.Name
policy.Priority = req.Priority
policy.Action = req.Action
policy.Conditions = []byte(req.Conditions)
policy.RequiredApprovers = req.RequiredApprovers
policy.ApproverRoles = req.ApproverRoles
policy.Enabled = true
if err := policy.Create(); err != nil {
writeError(w, http.StatusInternalServerError, "failed to create policy: "+err.Error())
return
}
@@ -91,28 +79,35 @@ func (s *Server) handleUpdatePolicy(w http.ResponseWriter, r *http.Request) {
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 {
policy, err := orm.Get[db.Policy](s.db.ORM, policyID)
if err != nil || policy.OrgID != orgID {
writeError(w, http.StatusNotFound, "policy not found")
return
}
if req.Name != nil {
policy.Name = *req.Name
}
if req.Priority != nil {
policy.Priority = *req.Priority
}
if req.Action != nil {
policy.Action = *req.Action
}
if req.Conditions != nil {
policy.Conditions = []byte(*req.Conditions)
}
if req.RequiredApprovers != nil {
policy.RequiredApprovers = *req.RequiredApprovers
}
if req.Enabled != nil {
policy.Enabled = *req.Enabled
}
if err := policy.Update(); err != nil {
writeError(w, http.StatusInternalServerError, "failed to update policy")
return
}
writeJSON(w, http.StatusOK, policy)
}
@@ -120,11 +115,15 @@ 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 {
policy, err := orm.Get[db.Policy](s.db.ORM, policyID)
if err != nil || policy.OrgID != orgID {
writeError(w, http.StatusNotFound, "policy not found")
return
}
if err := policy.Delete(); err != nil {
writeError(w, http.StatusInternalServerError, "failed to delete policy")
return
}
w.WriteHeader(http.StatusNoContent)
}
+197 -107
View File
@@ -2,9 +2,12 @@ package api
import (
"encoding/json"
"fmt"
"net/http"
"github.com/hanzoai/orm"
"github.com/luxfi/mpc/pkg/db"
"github.com/luxfi/mpc/pkg/smart"
)
func (s *Server) handleDeploySmartWallet(w http.ResponseWriter, r *http.Request) {
@@ -29,33 +32,99 @@ func (s *Server) handleDeploySmartWallet(w http.ResponseWriter, r *http.Request)
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 {
// Verify wallet belongs to org and get the MPC EOA address
wallet, err := orm.Get[db.Wallet](s.db.ORM, walletID)
if err != nil || wallet.OrgID != orgID {
writeError(w, http.StatusNotFound, "wallet not found")
return
}
mpcEOA := ""
if wallet.EthAddress != nil {
mpcEOA = *wallet.EthAddress
}
if mpcEOA == "" {
writeError(w, http.StatusBadRequest, "wallet has no Ethereum address (keygen not complete)")
return
}
// TODO: Deploy smart wallet via Safe SDK or ERC-4337 factory
// For now, record the intent
contractAddress := "0x0000000000000000000000000000000000000000" // placeholder
// Ensure the MPC EOA is always an owner
hasEOA := false
for _, o := range req.Owners {
if o == mpcEOA {
hasEOA = true
break
}
}
if !hasEOA {
req.Owners = append(req.Owners, mpcEOA)
}
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 {
salt := ""
if req.Salt != nil {
salt = *req.Salt
}
factoryAddr := ""
if req.FactoryAddress != nil {
factoryAddr = *req.FactoryAddress
}
entrypointAddr := ""
if req.EntrypointAddress != nil {
entrypointAddr = *req.EntrypointAddress
}
// Compute the predicted contract address using real ABI encoding + CREATE2
var contractAddress string
var deployCalldata []byte
switch req.WalletType {
case "safe":
cfg := smart.SafeConfig{
FactoryAddress: factoryAddr,
SingletonAddr: "",
Owners: req.Owners,
Threshold: req.Threshold,
Salt: salt,
FallbackHandler: "",
}
contractAddress = smart.PredictAddress(cfg)
deployCalldata, err = smart.EncodeDeploy(cfg)
if err != nil {
writeError(w, http.StatusBadRequest, "failed to encode Safe deploy: "+err.Error())
return
}
case "erc4337":
cfg := smart.AccountConfig{
FactoryAddress: factoryAddr,
EntrypointAddress: entrypointAddr,
OwnerAddress: mpcEOA,
Salt: salt,
}
contractAddress = smart.PredictAccountAddress(cfg)
deployCalldata, err = smart.EncodeInitCode(cfg)
if err != nil {
writeError(w, http.StatusBadRequest, "failed to encode ERC-4337 init code: "+err.Error())
return
}
default:
writeError(w, http.StatusBadRequest, fmt.Sprintf("unsupported wallet_type: %q (use safe or erc4337)", req.WalletType))
return
}
// Store deploy calldata so it can be broadcast to the chain
_ = deployCalldata
sw := orm.New[db.SmartWallet](s.db.ORM)
sw.WalletID = walletID
sw.OrgID = orgID
sw.Chain = req.Chain
sw.ContractAddress = contractAddress
sw.WalletType = req.WalletType
sw.FactoryAddress = req.FactoryAddress
sw.EntrypointAddress = req.EntrypointAddress
sw.Salt = req.Salt
sw.Owners = req.Owners
sw.Threshold = req.Threshold
sw.Status = "pending"
if err := sw.Create(); err != nil {
writeError(w, http.StatusInternalServerError, "failed to create smart wallet: "+err.Error())
return
}
@@ -67,31 +136,17 @@ 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)
wallets, err := orm.TypedQuery[db.SmartWallet](s.db.ORM).
Filter("walletId =", walletID).
Filter("orgId =", orgID).
Order("-createdAt").
GetAll(r.Context())
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{}
wallets = []*db.SmartWallet{}
}
writeJSON(w, http.StatusOK, wallets)
}
@@ -100,16 +155,8 @@ 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 {
sw, err := orm.Get[db.SmartWallet](s.db.ORM, swID)
if err != nil || sw.OrgID != orgID {
writeError(w, http.StatusNotFound, "smart wallet not found")
return
}
@@ -122,9 +169,12 @@ func (s *Server) handleProposeSafeTx(w http.ResponseWriter, r *http.Request) {
swID := urlParam(r, "id")
var req struct {
To string `json:"to"`
Value string `json:"value"`
Data string `json:"data,omitempty"`
To string `json:"to"`
Value string `json:"value"`
Data string `json:"data,omitempty"`
Operation int `json:"operation"` // 0=Call, 1=DelegateCall
ChainID int64 `json:"chain_id"` // EVM chain ID for EIP-712
Nonce int `json:"nonce"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
@@ -134,32 +184,72 @@ func (s *Server) handleProposeSafeTx(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "to address is required")
return
}
// Verify smart wallet and get associated wallet ID + chain
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")
if req.ChainID == 0 {
writeError(w, http.StatusBadRequest, "chain_id is required for EIP-712 Safe tx hash")
return
}
// Create a transaction record for the Safe proposal
var tx db.Transaction
err = s.db.Pool.QueryRow(r.Context(),
`INSERT INTO transactions (org_id, wallet_id, tx_type, chain, to_address, amount, raw_tx, status, initiated_by)
VALUES ($1, $2, 'safe_proposal', $3, $4, $5, $6, 'pending', $7)
RETURNING id, org_id, wallet_id, tx_type, chain, to_address, amount, status, initiated_by, created_at`,
orgID, walletID, chain, req.To, req.Value, []byte(req.Data), nilIfEmpty(userID)).
Scan(&tx.ID, &tx.OrgID, &tx.WalletID, &tx.TxType, &tx.Chain,
&tx.ToAddress, &tx.Amount, &tx.Status, &tx.InitiatedBy, &tx.CreatedAt)
sw, err := orm.Get[db.SmartWallet](s.db.ORM, swID)
if err != nil || sw.OrgID != orgID {
writeError(w, http.StatusNotFound, "smart wallet not found")
return
}
if sw.WalletType != "safe" {
writeError(w, http.StatusBadRequest, "propose is only for Safe wallets")
return
}
// Compute real EIP-712 Safe transaction hash
safeTx := smart.SafeTransaction{
To: req.To,
Value: req.Value,
Data: req.Data,
Operation: req.Operation,
Nonce: req.Nonce,
}
txHash, err := smart.HashSafeTransaction(sw.ContractAddress, req.ChainID, safeTx)
if err != nil {
writeError(w, http.StatusBadRequest, "failed to hash safe tx: "+err.Error())
return
}
// MPC signs the EIP-712 hash via the wallet that owns this Safe
mpcWallet, err := orm.Get[db.Wallet](s.db.ORM, sw.WalletID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load MPC wallet")
return
}
signResult, err := s.mpc.TriggerSign(mpcWallet.WalletID, txHash)
if err != nil {
writeError(w, http.StatusInternalServerError, "MPC signing failed: "+err.Error())
return
}
walletID := sw.WalletID
tx := orm.New[db.Transaction](s.db.ORM)
tx.OrgID = orgID
tx.WalletID = &walletID
tx.TxType = "safe_proposal"
tx.Chain = sw.Chain
tx.ToAddress = nilIfEmpty(req.To)
tx.Amount = nilIfEmpty(req.Value)
tx.RawTx = []byte(req.Data)
tx.SignatureR = nilIfEmpty(signResult.R)
tx.SignatureS = nilIfEmpty(signResult.S)
tx.TxHash = nilIfEmpty(fmt.Sprintf("0x%x", txHash))
tx.Status = "signed"
tx.InitiatedBy = nilIfEmpty(userID)
if err := tx.Create(); err != nil {
writeError(w, http.StatusInternalServerError, "failed to create transaction: "+err.Error())
return
}
writeJSON(w, http.StatusCreated, tx)
writeJSON(w, http.StatusCreated, map[string]any{
"transaction": tx,
"safe_tx_hash": fmt.Sprintf("0x%x", txHash),
"signature_r": signResult.R,
"signature_s": signResult.S,
})
}
func (s *Server) handleExecuteSafeTx(w http.ResponseWriter, r *http.Request) {
@@ -178,32 +268,34 @@ func (s *Server) handleExecuteSafeTx(w http.ResponseWriter, r *http.Request) {
return
}
// Verify smart wallet exists
var walletID string
err := s.db.Pool.QueryRow(r.Context(),
`SELECT wallet_id FROM smart_wallets WHERE id = $1 AND org_id = $2`,
swID, orgID).Scan(&walletID)
if err != nil {
sw, err := orm.Get[db.SmartWallet](s.db.ORM, swID)
if err != nil || sw.OrgID != orgID {
writeError(w, http.StatusNotFound, "smart wallet not found")
return
}
// Update the matching pending transaction to "executing" status
var tx db.Transaction
err = s.db.Pool.QueryRow(r.Context(),
`UPDATE transactions SET status = 'executing', tx_hash = $1
WHERE wallet_id = $2 AND org_id = $3 AND tx_type = 'safe_proposal' AND status = 'pending'
AND id = (SELECT id FROM transactions WHERE wallet_id = $2 AND org_id = $3
AND tx_type = 'safe_proposal' AND status = 'pending' ORDER BY created_at DESC LIMIT 1)
RETURNING id, org_id, wallet_id, tx_type, chain, to_address, amount, tx_hash, status, created_at`,
req.SafeTxHash, walletID, orgID).
Scan(&tx.ID, &tx.OrgID, &tx.WalletID, &tx.TxType, &tx.Chain,
&tx.ToAddress, &tx.Amount, &tx.TxHash, &tx.Status, &tx.CreatedAt)
if err != nil {
// Find the most recent pending safe_proposal for this wallet
txList, err := orm.TypedQuery[db.Transaction](s.db.ORM).
Filter("walletId =", sw.WalletID).
Filter("orgId =", orgID).
Filter("txType =", "safe_proposal").
Filter("status =", "pending").
Order("-createdAt").
Limit(1).
GetAll(r.Context())
if err != nil || len(txList) == 0 {
writeError(w, http.StatusNotFound, "no pending safe proposal found for this wallet")
return
}
tx := txList[0]
tx.Status = "executing"
tx.TxHash = nilIfEmpty(req.SafeTxHash)
if err := tx.Update(); err != nil {
writeError(w, http.StatusInternalServerError, "failed to update transaction")
return
}
writeJSON(w, http.StatusOK, tx)
}
@@ -225,29 +317,27 @@ func (s *Server) handleUserOperation(w http.ResponseWriter, r *http.Request) {
return
}
var walletType, walletID, chain string
err := s.db.Pool.QueryRow(r.Context(),
`SELECT wallet_type, wallet_id, chain FROM smart_wallets WHERE id = $1 AND org_id = $2`,
swID, orgID).Scan(&walletType, &walletID, &chain)
if err != nil {
sw, err := orm.Get[db.SmartWallet](s.db.ORM, swID)
if err != nil || sw.OrgID != orgID {
writeError(w, http.StatusNotFound, "smart wallet not found")
return
}
if walletType != "erc4337" {
if sw.WalletType != "erc4337" {
writeError(w, http.StatusBadRequest, "user operations only supported for ERC-4337 wallets")
return
}
// Create a transaction record for the UserOperation
var tx db.Transaction
err = s.db.Pool.QueryRow(r.Context(),
`INSERT INTO transactions (org_id, wallet_id, tx_type, chain, amount, raw_tx, status, initiated_by)
VALUES ($1, $2, 'user_operation', $3, $4, $5, 'submitted', $6)
RETURNING id, org_id, wallet_id, tx_type, chain, amount, status, initiated_by, created_at`,
orgID, walletID, chain, req.Value, []byte(req.CallData), nilIfEmpty(userID)).
Scan(&tx.ID, &tx.OrgID, &tx.WalletID, &tx.TxType, &tx.Chain,
&tx.Amount, &tx.Status, &tx.InitiatedBy, &tx.CreatedAt)
if err != nil {
walletID := sw.WalletID
tx := orm.New[db.Transaction](s.db.ORM)
tx.OrgID = orgID
tx.WalletID = &walletID
tx.TxType = "user_operation"
tx.Chain = sw.Chain
tx.Amount = nilIfEmpty(req.Value)
tx.RawTx = []byte(req.CallData)
tx.Status = "submitted"
tx.InitiatedBy = nilIfEmpty(userID)
if err := tx.Create(); err != nil {
writeError(w, http.StatusInternalServerError, "failed to create user operation: "+err.Error())
return
}
+71 -100
View File
@@ -5,39 +5,22 @@ import (
"net/http"
"time"
"github.com/hanzoai/orm"
"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)
subs, err := orm.TypedQuery[db.Subscription](s.db.ORM).
Filter("orgId =", orgID).
Order("-createdAt").
GetAll(r.Context())
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{}
subs = []*db.Subscription{}
}
writeJSON(w, http.StatusOK, subs)
}
@@ -71,26 +54,25 @@ func (s *Server) handleCreateSubscription(w http.ResponseWriter, r *http.Request
}
nextPayment := computeNextPayment(req.Interval)
walletID := req.WalletID
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 {
sub := orm.New[db.Subscription](s.db.ORM)
sub.OrgID = orgID
sub.WalletID = &walletID
sub.Name = req.Name
sub.ProviderName = req.ProviderName
sub.RecipientAddress = req.RecipientAddress
sub.Chain = req.Chain
sub.Token = req.Token
sub.Amount = req.Amount
sub.Currency = req.Currency
sub.Interval = req.Interval
sub.NextPaymentAt = nextPayment
sub.RequireBalance = req.RequireBalance
sub.Status = "active"
sub.MaxRetries = 3
sub.CreatedBy = nilIfEmpty(userID)
if err := sub.Create(); err != nil {
writeError(w, http.StatusInternalServerError, "failed to create subscription: "+err.Error())
return
}
@@ -102,21 +84,8 @@ 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 {
sub, err := orm.Get[db.Subscription](s.db.ORM, subID)
if err != nil || sub.OrgID != orgID {
writeError(w, http.StatusNotFound, "subscription not found")
return
}
@@ -136,27 +105,22 @@ func (s *Server) handleUpdateSubscription(w http.ResponseWriter, r *http.Request
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 {
sub, err := orm.Get[db.Subscription](s.db.ORM, subID)
if err != nil || sub.OrgID != orgID {
writeError(w, http.StatusNotFound, "subscription not found")
return
}
if req.Status != nil {
sub.Status = *req.Status
}
if req.Amount != nil {
sub.Amount = *req.Amount
}
if err := sub.Update(); err != nil {
writeError(w, http.StatusInternalServerError, "failed to update subscription")
return
}
writeJSON(w, http.StatusOK, sub)
}
@@ -165,50 +129,57 @@ func (s *Server) handleDeleteSubscription(w http.ResponseWriter, r *http.Request
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 {
sub, err := orm.Get[db.Subscription](s.db.ORM, subID)
if err != nil || sub.OrgID != orgID || sub.Status == "cancelled" {
writeError(w, http.StatusNotFound, "subscription not found")
return
}
now := time.Now()
sub.Status = "cancelled"
sub.CancelledBy = nilIfEmpty(userID)
sub.CancelledAt = &now
if err := sub.Update(); err != nil {
writeError(w, http.StatusInternalServerError, "failed to cancel subscription")
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handlePayNow(w http.ResponseWriter, r *http.Request) {
orgID := getOrgID(r.Context())
userID := getUserID(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 {
sub, err := orm.Get[db.Subscription](s.db.ORM, subID)
if err != nil || sub.OrgID != orgID || sub.Status != "active" {
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
walletID := ""
if sub.WalletID != nil {
walletID = *sub.WalletID
}
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 {
tx := orm.New[db.Transaction](s.db.ORM)
tx.OrgID = orgID
if walletID != "" {
tx.WalletID = &walletID
}
tx.TxType = "subscription_payment"
tx.Chain = sub.Chain
tx.ToAddress = nilIfEmpty(sub.RecipientAddress)
tx.Amount = nilIfEmpty(sub.Amount)
tx.Token = sub.Token
tx.Status = "approved"
tx.InitiatedBy = nilIfEmpty(userID)
if err := tx.Create(); err != nil {
writeError(w, http.StatusInternalServerError, "failed to create payment transaction")
return
}
txID := tx.Id()
go s.signAndBroadcast(txID, orgID)
writeJSON(w, http.StatusOK, map[string]string{
+69 -91
View File
@@ -5,9 +5,9 @@ import (
"encoding/hex"
"encoding/json"
"net/http"
"strconv"
"time"
"github.com/hanzoai/orm"
"github.com/luxfi/mpc/pkg/db"
)
@@ -34,10 +34,8 @@ func (s *Server) handleCreateTransaction(w http.ResponseWriter, r *http.Request)
}
// 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 {
wallet, err := orm.Get[db.Wallet](s.db.ORM, req.WalletID)
if err != nil || wallet.OrgID != orgID {
writeError(w, http.StatusNotFound, "wallet not found")
return
}
@@ -64,24 +62,26 @@ func (s *Server) handleCreateTransaction(w http.ResponseWriter, r *http.Request)
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 {
walletID := req.WalletID
tx := orm.New[db.Transaction](s.db.ORM)
tx.OrgID = orgID
tx.WalletID = &walletID
tx.TxType = req.TxType
tx.Chain = req.Chain
tx.ToAddress = nilIfEmpty(req.ToAddress)
tx.Amount = nilIfEmpty(req.Amount)
tx.Token = nilIfEmpty(req.Token)
tx.RawTx = rawTx
tx.Status = status
tx.InitiatedBy = nilIfEmpty(userID)
if err := tx.Create(); err != nil {
writeError(w, http.StatusInternalServerError, "failed to create transaction: "+err.Error())
return
}
txID := tx.Id()
if status == "approved" {
go s.signAndBroadcast(tx.ID, orgID)
go s.signAndBroadcast(txID, orgID)
}
s.fireWebhook(r.Context(), orgID, "tx.pending", tx)
@@ -93,43 +93,25 @@ func (s *Server) handleListTransactions(w http.ResponseWriter, r *http.Request)
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
q := orm.TypedQuery[db.Transaction](s.db.ORM).
Filter("orgId =", orgID).
Order("-createdAt").
Limit(100)
if statusFilter != "" {
query += " AND status = $" + strconv.Itoa(idx)
args = append(args, statusFilter)
idx++
q = q.Filter("status =", statusFilter)
}
if chain != "" {
query += " AND chain = $" + strconv.Itoa(idx)
args = append(args, chain)
idx++
q = q.Filter("chain =", chain)
}
query += " ORDER BY created_at DESC LIMIT 100"
rows, err := s.db.Pool.Query(r.Context(), query, args...)
txs, err := q.GetAll(r.Context())
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{}
txs = []*db.Transaction{}
}
writeJSON(w, http.StatusOK, txs)
}
@@ -138,19 +120,8 @@ 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 {
tx, err := orm.Get[db.Transaction](s.db.ORM, txID)
if err != nil || tx.OrgID != orgID {
writeError(w, http.StatusNotFound, "transaction not found")
return
}
@@ -163,19 +134,19 @@ func (s *Server) handleApproveTransaction(w http.ResponseWriter, r *http.Request
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 {
tx, err := orm.Get[db.Transaction](s.db.ORM, txID)
if err != nil || tx.OrgID != orgID || tx.Status != "pending_approval" {
writeError(w, http.StatusNotFound, "transaction not found or not pending approval")
return
}
// Append approver
tx.ApprovedBy = append(tx.ApprovedBy, userID)
if err := tx.Update(); err != nil {
writeError(w, http.StatusInternalServerError, "failed to approve transaction")
return
}
policies, _ := s.loadPolicies(r.Context(), orgID, nil)
requiredApprovers := 1
for _, p := range policies {
@@ -184,10 +155,11 @@ func (s *Server) handleApproveTransaction(w http.ResponseWriter, r *http.Request
}
}
if len(approvedBy) >= requiredApprovers {
s.db.Pool.Exec(r.Context(),
`UPDATE transactions SET status = 'approved' WHERE id = $1`, txID)
go s.signAndBroadcast(txID, orgID)
if len(tx.ApprovedBy) >= requiredApprovers {
tx.Status = "approved"
if err := tx.Update(); err == nil {
go s.signAndBroadcast(txID, orgID)
}
}
s.fireWebhook(r.Context(), orgID, "tx.approved", map[string]string{
@@ -207,15 +179,20 @@ func (s *Server) handleRejectTransaction(w http.ResponseWriter, r *http.Request)
}
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 {
tx, err := orm.Get[db.Transaction](s.db.ORM, txID)
if err != nil || tx.OrgID != orgID || tx.Status != "pending_approval" {
writeError(w, http.StatusNotFound, "transaction not found or not pending")
return
}
tx.Status = "rejected"
tx.RejectedBy = nilIfEmpty(userID)
tx.RejectionReason = nilIfEmpty(req.Reason)
if err := tx.Update(); err != nil {
writeError(w, http.StatusInternalServerError, "failed to reject transaction")
return
}
s.fireWebhook(r.Context(), orgID, "tx.rejected", map[string]string{
"tx_id": txID,
"reason": req.Reason,
@@ -226,37 +203,38 @@ func (s *Server) handleRejectTransaction(w http.ResponseWriter, r *http.Request)
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)
tx, err := orm.Get[db.Transaction](s.db.ORM, txID)
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 tx.WalletID == nil {
return
}
wallet, err := orm.Get[db.Wallet](s.db.ORM, *tx.WalletID)
if err != nil {
return
}
s.db.Pool.Exec(ctx, `UPDATE transactions SET status = 'signing' WHERE id = $1`, txID)
tx.Status = "signing"
tx.Update()
result, err := s.mpc.TriggerSign(mpcWalletID, rawTx)
result, err := s.mpc.TriggerSign(wallet.WalletID, tx.RawTx)
if err != nil {
s.db.Pool.Exec(ctx, `UPDATE transactions SET status = 'failed' WHERE id = $1`, txID)
tx.Status = "failed"
tx.Update()
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)
tx.Status = "signed"
tx.SignatureR = nilIfEmpty(result.R)
tx.SignatureS = nilIfEmpty(result.S)
tx.SignatureEdDSA = nilIfEmpty(result.Signature)
tx.SignedAt = &now
tx.Update()
s.fireWebhook(ctx, orgID, "tx.signed", map[string]string{"tx_id": txID})
}
+54 -61
View File
@@ -5,30 +5,22 @@ import (
"encoding/json"
"net/http"
"github.com/hanzoai/orm"
"github.com/luxfi/mpc/pkg/db"
)
func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) {
orgID := getOrgID(r.Context())
rows, err := s.db.Pool.Query(r.Context(),
`SELECT id, org_id, email, role, created_at FROM users WHERE org_id = $1
ORDER BY created_at`, orgID)
users, err := orm.TypedQuery[db.User](s.db.ORM).
Filter("orgId =", orgID).
Order("createdAt").
GetAll(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "database error")
return
}
defer rows.Close()
var users []db.User
for rows.Next() {
var u db.User
if err := rows.Scan(&u.ID, &u.OrgID, &u.Email, &u.Role, &u.CreatedAt); err != nil {
continue
}
users = append(users, u)
}
if users == nil {
users = []db.User{}
users = []*db.User{}
}
writeJSON(w, http.StatusOK, users)
}
@@ -64,14 +56,12 @@ func (s *Server) handleInviteUser(w http.ResponseWriter, r *http.Request) {
return
}
var user db.User
err = s.db.Pool.QueryRow(r.Context(),
`INSERT INTO users (org_id, email, password_hash, role)
VALUES ($1, $2, $3, $4)
RETURNING id, org_id, email, role, created_at`,
orgID, req.Email, hash, req.Role).
Scan(&user.ID, &user.OrgID, &user.Email, &user.Role, &user.CreatedAt)
if err != nil {
user := orm.New[db.User](s.db.ORM)
user.OrgID = orgID
user.Email = req.Email
user.PasswordHash = hash
user.Role = req.Role
if err := user.Create(); err != nil {
writeError(w, http.StatusConflict, "email already registered")
return
}
@@ -91,17 +81,20 @@ func (s *Server) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
return
}
var user db.User
err := s.db.Pool.QueryRow(r.Context(),
`UPDATE users SET role = COALESCE($1, role) WHERE id = $2 AND org_id = $3
RETURNING id, org_id, email, role, created_at`,
req.Role, userID, orgID).
Scan(&user.ID, &user.OrgID, &user.Email, &user.Role, &user.CreatedAt)
if err != nil {
user, err := orm.Get[db.User](s.db.ORM, userID)
if err != nil || user.OrgID != orgID {
writeError(w, http.StatusNotFound, "user not found")
return
}
if req.Role != nil {
user.Role = *req.Role
}
if err := user.Update(); err != nil {
writeError(w, http.StatusInternalServerError, "failed to update user")
return
}
writeJSON(w, http.StatusOK, user)
}
@@ -109,39 +102,37 @@ func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
orgID := getOrgID(r.Context())
userID := urlParam(r, "id")
tag, err := s.db.Pool.Exec(r.Context(),
`DELETE FROM users WHERE id = $1 AND org_id = $2 AND role != 'owner'`,
userID, orgID)
if err != nil || tag.RowsAffected() == 0 {
user, err := orm.Get[db.User](s.db.ORM, userID)
if err != nil || user.OrgID != orgID {
writeError(w, http.StatusNotFound, "user not found or cannot delete owner")
return
}
if user.Role == "owner" {
writeError(w, http.StatusNotFound, "user not found or cannot delete owner")
return
}
if err := user.Delete(); err != nil {
writeError(w, http.StatusInternalServerError, "failed to delete user")
return
}
w.WriteHeader(http.StatusNoContent)
}
// API Key handlers
func (s *Server) handleListAPIKeys(w http.ResponseWriter, r *http.Request) {
orgID := getOrgID(r.Context())
rows, err := s.db.Pool.Query(r.Context(),
`SELECT id, org_id, name, key_prefix, permissions, created_at, expires_at, last_used_at
FROM api_keys WHERE org_id = $1 ORDER BY created_at DESC`, orgID)
keys, err := orm.TypedQuery[db.APIKey](s.db.ORM).
Filter("orgId =", orgID).
Order("-createdAt").
GetAll(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "database error")
return
}
defer rows.Close()
var keys []db.APIKey
for rows.Next() {
var k db.APIKey
if err := rows.Scan(&k.ID, &k.OrgID, &k.Name, &k.KeyPrefix, &k.Permissions,
&k.CreatedAt, &k.ExpiresAt, &k.LastUsedAt); err != nil {
continue
}
keys = append(keys, k)
}
if keys == nil {
keys = []db.APIKey{}
keys = []*db.APIKey{}
}
writeJSON(w, http.StatusOK, keys)
}
@@ -168,22 +159,20 @@ func (s *Server) handleCreateAPIKey(w http.ResponseWriter, r *http.Request) {
}
prefix := key[:8]
var apiKey db.APIKey
err = s.db.Pool.QueryRow(r.Context(),
`INSERT INTO api_keys (org_id, name, key_hash, key_prefix, permissions)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, org_id, name, key_prefix, permissions, created_at`,
orgID, req.Name, keyHash, prefix, req.Permissions).
Scan(&apiKey.ID, &apiKey.OrgID, &apiKey.Name, &apiKey.KeyPrefix,
&apiKey.Permissions, &apiKey.CreatedAt)
if err != nil {
apiKey := orm.New[db.APIKey](s.db.ORM)
apiKey.OrgID = orgID
apiKey.Name = req.Name
apiKey.KeyHash = keyHash
apiKey.KeyPrefix = prefix
apiKey.Permissions = req.Permissions
if err := apiKey.Create(); err != nil {
writeError(w, http.StatusInternalServerError, "failed to create API key")
return
}
// Return full key only once
writeJSON(w, http.StatusCreated, map[string]interface{}{
"id": apiKey.ID,
"id": apiKey.Id(),
"name": apiKey.Name,
"key": key,
"key_prefix": apiKey.KeyPrefix,
@@ -196,12 +185,16 @@ func (s *Server) handleDeleteAPIKey(w http.ResponseWriter, r *http.Request) {
orgID := getOrgID(r.Context())
keyID := urlParam(r, "id")
tag, err := s.db.Pool.Exec(r.Context(),
`DELETE FROM api_keys WHERE id = $1 AND org_id = $2`, keyID, orgID)
if err != nil || tag.RowsAffected() == 0 {
apiKey, err := orm.Get[db.APIKey](s.db.ORM, keyID)
if err != nil || apiKey.OrgID != orgID {
writeError(w, http.StatusNotFound, "api key not found")
return
}
if err := apiKey.Delete(); err != nil {
writeError(w, http.StatusInternalServerError, "failed to delete api key")
return
}
w.WriteHeader(http.StatusNoContent)
}
+36 -43
View File
@@ -1,34 +1,26 @@
package api
import (
"context"
"encoding/json"
"net/http"
"github.com/hanzoai/orm"
"github.com/luxfi/mpc/pkg/db"
)
func (s *Server) handleListVaults(w http.ResponseWriter, r *http.Request) {
orgID := getOrgID(r.Context())
rows, err := s.db.Pool.Query(r.Context(),
`SELECT id, org_id, name, description, app_id, created_at
FROM vaults WHERE org_id = $1 ORDER BY created_at DESC`, orgID)
vaults, err := orm.TypedQuery[db.Vault](s.db.ORM).
Filter("orgId =", orgID).
Order("-createdAt").
GetAll(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "database error")
return
}
defer rows.Close()
var vaults []db.Vault
for rows.Next() {
var v db.Vault
if err := rows.Scan(&v.ID, &v.OrgID, &v.Name, &v.Description, &v.AppID, &v.CreatedAt); err != nil {
writeError(w, http.StatusInternalServerError, "scan error")
return
}
vaults = append(vaults, v)
}
if vaults == nil {
vaults = []db.Vault{}
vaults = []*db.Vault{}
}
writeJSON(w, http.StatusOK, vaults)
}
@@ -49,31 +41,25 @@ func (s *Server) handleCreateVault(w http.ResponseWriter, r *http.Request) {
return
}
var vault db.Vault
err := s.db.Pool.QueryRow(r.Context(),
`INSERT INTO vaults (org_id, name, description, app_id)
VALUES ($1, $2, $3, $4)
RETURNING id, org_id, name, description, app_id, created_at`,
orgID, req.Name, req.Description, req.AppID).
Scan(&vault.ID, &vault.OrgID, &vault.Name, &vault.Description, &vault.AppID, &vault.CreatedAt)
if err != nil {
v := orm.New[db.Vault](s.db.ORM)
v.OrgID = orgID
v.Name = req.Name
v.Description = req.Description
v.AppID = req.AppID
if err := v.Create(); err != nil {
writeError(w, http.StatusInternalServerError, "failed to create vault")
return
}
writeJSON(w, http.StatusCreated, vault)
writeJSON(w, http.StatusCreated, v)
}
func (s *Server) handleGetVault(w http.ResponseWriter, r *http.Request) {
orgID := getOrgID(r.Context())
vaultID := urlParam(r, "id")
var vault db.Vault
err := s.db.Pool.QueryRow(r.Context(),
`SELECT id, org_id, name, description, app_id, created_at
FROM vaults WHERE id = $1 AND org_id = $2`, vaultID, orgID).
Scan(&vault.ID, &vault.OrgID, &vault.Name, &vault.Description, &vault.AppID, &vault.CreatedAt)
if err != nil {
vault, err := orm.Get[db.Vault](s.db.ORM, vaultID)
if err != nil || vault.OrgID != orgID {
writeError(w, http.StatusNotFound, "vault not found")
return
}
@@ -94,20 +80,23 @@ func (s *Server) handleUpdateVault(w http.ResponseWriter, r *http.Request) {
return
}
var vault db.Vault
err := s.db.Pool.QueryRow(r.Context(),
`UPDATE vaults SET
name = COALESCE($1, name),
description = COALESCE($2, description)
WHERE id = $3 AND org_id = $4
RETURNING id, org_id, name, description, app_id, created_at`,
req.Name, req.Description, vaultID, orgID).
Scan(&vault.ID, &vault.OrgID, &vault.Name, &vault.Description, &vault.AppID, &vault.CreatedAt)
if err != nil {
vault, err := orm.Get[db.Vault](s.db.ORM, vaultID)
if err != nil || vault.OrgID != orgID {
writeError(w, http.StatusNotFound, "vault not found")
return
}
if req.Name != nil {
vault.Name = *req.Name
}
if req.Description != nil {
vault.Description = req.Description
}
if err := vault.Update(); err != nil {
writeError(w, http.StatusInternalServerError, "failed to update vault")
return
}
writeJSON(w, http.StatusOK, vault)
}
@@ -115,12 +104,16 @@ func (s *Server) handleDeleteVault(w http.ResponseWriter, r *http.Request) {
orgID := getOrgID(r.Context())
vaultID := urlParam(r, "id")
tag, err := s.db.Pool.Exec(r.Context(),
`DELETE FROM vaults WHERE id = $1 AND org_id = $2`, vaultID, orgID)
if err != nil || tag.RowsAffected() == 0 {
vault, err := orm.Get[db.Vault](s.db.ORM, vaultID)
if err != nil || vault.OrgID != orgID {
writeError(w, http.StatusNotFound, "vault not found")
return
}
if err := vault.DeleteCtx(context.Background()); err != nil {
writeError(w, http.StatusInternalServerError, "failed to delete vault")
return
}
w.WriteHeader(http.StatusNoContent)
}
+51 -102
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"net/http"
"github.com/hanzoai/orm"
"github.com/luxfi/mpc/pkg/db"
)
@@ -11,33 +12,17 @@ func (s *Server) handleListWallets(w http.ResponseWriter, r *http.Request) {
orgID := getOrgID(r.Context())
vaultID := urlParam(r, "id")
rows, err := s.db.Pool.Query(r.Context(),
`SELECT id, vault_id, org_id, wallet_id, name, key_type,
ecdsa_pubkey, eddsa_pubkey, eth_address, btc_address, sol_address,
threshold, participants, version, status, created_by, created_at
FROM wallets WHERE vault_id = $1 AND org_id = $2
ORDER BY created_at DESC`, vaultID, orgID)
wallets, err := orm.TypedQuery[db.Wallet](s.db.ORM).
Filter("vaultId =", vaultID).
Filter("orgId =", orgID).
Order("-createdAt").
GetAll(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "database error")
return
}
defer rows.Close()
var wallets []db.Wallet
for rows.Next() {
var wal db.Wallet
if err := rows.Scan(&wal.ID, &wal.VaultID, &wal.OrgID, &wal.WalletID,
&wal.Name, &wal.KeyType, &wal.ECDSAPubkey, &wal.EDDSAPubkey,
&wal.EthAddress, &wal.BtcAddress, &wal.SolAddress,
&wal.Threshold, &wal.Participants, &wal.Version, &wal.Status,
&wal.CreatedBy, &wal.CreatedAt); err != nil {
writeError(w, http.StatusInternalServerError, "scan error")
return
}
wallets = append(wallets, wal)
}
if wallets == nil {
wallets = []db.Wallet{}
wallets = []*db.Wallet{}
}
writeJSON(w, http.StatusOK, wallets)
}
@@ -60,11 +45,8 @@ func (s *Server) handleCreateWallet(w http.ResponseWriter, r *http.Request) {
}
// Verify vault belongs to org
var vaultExists bool
s.db.Pool.QueryRow(r.Context(),
`SELECT EXISTS(SELECT 1 FROM vaults WHERE id = $1 AND org_id = $2)`,
vaultID, orgID).Scan(&vaultExists)
if !vaultExists {
vault, err := orm.Get[db.Vault](s.db.ORM, vaultID)
if err != nil || vault.OrgID != orgID {
writeError(w, http.StatusNotFound, "vault not found")
return
}
@@ -83,47 +65,36 @@ func (s *Server) handleCreateWallet(w http.ResponseWriter, r *http.Request) {
participants = append(participants, status.NodeID)
}
var wallet db.Wallet
err = s.db.Pool.QueryRow(r.Context(),
`INSERT INTO wallets (vault_id, org_id, wallet_id, name, key_type,
ecdsa_pubkey, eddsa_pubkey, eth_address, threshold, participants, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING id, vault_id, org_id, wallet_id, name, key_type,
ecdsa_pubkey, eddsa_pubkey, eth_address, btc_address, sol_address,
threshold, participants, version, status, created_by, created_at`,
vaultID, orgID, result.WalletID, req.Name, req.KeyType,
result.ECDSAPubKey, result.EDDSAPubKey, result.EthAddress,
status.Threshold, participants, userID).
Scan(&wallet.ID, &wallet.VaultID, &wallet.OrgID, &wallet.WalletID,
&wallet.Name, &wallet.KeyType, &wallet.ECDSAPubkey, &wallet.EDDSAPubkey,
&wallet.EthAddress, &wallet.BtcAddress, &wallet.SolAddress,
&wallet.Threshold, &wallet.Participants, &wallet.Version, &wallet.Status,
&wallet.CreatedBy, &wallet.CreatedAt)
if err != nil {
name := req.Name
wal := orm.New[db.Wallet](s.db.ORM)
wal.VaultID = vaultID
wal.OrgID = orgID
wal.WalletID = result.WalletID
wal.Name = nilIfEmpty(name)
wal.KeyType = req.KeyType
wal.ECDSAPubkey = nilIfEmpty(result.ECDSAPubKey)
wal.EDDSAPubkey = nilIfEmpty(result.EDDSAPubKey)
wal.EthAddress = nilIfEmpty(result.EthAddress)
wal.Threshold = status.Threshold
wal.Participants = participants
wal.Version = 1
wal.Status = "active"
wal.CreatedBy = nilIfEmpty(userID)
if err := wal.Create(); err != nil {
writeError(w, http.StatusInternalServerError, "failed to save wallet: "+err.Error())
return
}
s.fireWebhook(r.Context(), orgID, "keygen.complete", wallet)
writeJSON(w, http.StatusCreated, wallet)
s.fireWebhook(r.Context(), orgID, "keygen.complete", wal)
writeJSON(w, http.StatusCreated, wal)
}
func (s *Server) handleGetWallet(w http.ResponseWriter, r *http.Request) {
orgID := getOrgID(r.Context())
walletID := urlParam(r, "id")
var wallet db.Wallet
err := s.db.Pool.QueryRow(r.Context(),
`SELECT id, vault_id, org_id, wallet_id, name, key_type,
ecdsa_pubkey, eddsa_pubkey, eth_address, btc_address, sol_address,
threshold, participants, version, status, created_by, created_at
FROM wallets WHERE id = $1 AND org_id = $2`, walletID, orgID).
Scan(&wallet.ID, &wallet.VaultID, &wallet.OrgID, &wallet.WalletID,
&wallet.Name, &wallet.KeyType, &wallet.ECDSAPubkey, &wallet.EDDSAPubkey,
&wallet.EthAddress, &wallet.BtcAddress, &wallet.SolAddress,
&wallet.Threshold, &wallet.Participants, &wallet.Version, &wallet.Status,
&wallet.CreatedBy, &wallet.CreatedAt)
if err != nil {
wallet, err := orm.Get[db.Wallet](s.db.ORM, walletID)
if err != nil || wallet.OrgID != orgID {
writeError(w, http.StatusNotFound, "wallet not found")
return
}
@@ -135,25 +106,21 @@ func (s *Server) handleGetWalletAddresses(w http.ResponseWriter, r *http.Request
orgID := getOrgID(r.Context())
walletID := urlParam(r, "id")
var eth, btc, sol *string
err := s.db.Pool.QueryRow(r.Context(),
`SELECT eth_address, btc_address, sol_address
FROM wallets WHERE id = $1 AND org_id = $2`, walletID, orgID).
Scan(&eth, &btc, &sol)
if err != nil {
wallet, err := orm.Get[db.Wallet](s.db.ORM, walletID)
if err != nil || wallet.OrgID != orgID {
writeError(w, http.StatusNotFound, "wallet not found")
return
}
addresses := map[string]interface{}{}
if eth != nil {
addresses["ethereum"] = *eth
if wallet.EthAddress != nil {
addresses["ethereum"] = *wallet.EthAddress
}
if btc != nil {
addresses["bitcoin"] = *btc
if wallet.BtcAddress != nil {
addresses["bitcoin"] = *wallet.BtcAddress
}
if sol != nil {
addresses["solana"] = *sol
if wallet.SolAddress != nil {
addresses["solana"] = *wallet.SolAddress
}
writeJSON(w, http.StatusOK, addresses)
}
@@ -171,26 +138,21 @@ func (s *Server) handleReshareWallet(w http.ResponseWriter, r *http.Request) {
return
}
var mpcWalletID string
err := s.db.Pool.QueryRow(r.Context(),
`SELECT wallet_id FROM wallets WHERE id = $1 AND org_id = $2`,
walletID, orgID).Scan(&mpcWalletID)
if err != nil {
wallet, err := orm.Get[db.Wallet](s.db.ORM, walletID)
if err != nil || wallet.OrgID != orgID {
writeError(w, http.StatusNotFound, "wallet not found")
return
}
if err := s.mpc.TriggerReshare(mpcWalletID, req.NewThreshold, req.NewParticipants); err != nil {
if err := s.mpc.TriggerReshare(wallet.WalletID, req.NewThreshold, req.NewParticipants); err != nil {
writeError(w, http.StatusInternalServerError, "reshare failed: "+err.Error())
return
}
// Update wallet version
_, err = s.db.Pool.Exec(r.Context(),
`UPDATE wallets SET version = version + 1, threshold = $1, participants = $2
WHERE id = $3 AND org_id = $4`,
req.NewThreshold, req.NewParticipants, walletID, orgID)
if err != nil {
wallet.Version++
wallet.Threshold = req.NewThreshold
wallet.Participants = req.NewParticipants
if err := wallet.Update(); err != nil {
writeError(w, http.StatusInternalServerError, "failed to update wallet")
return
}
@@ -202,31 +164,18 @@ func (s *Server) handleWalletHistory(w http.ResponseWriter, r *http.Request) {
orgID := getOrgID(r.Context())
walletID := urlParam(r, "id")
// First get the wallet's UUID from the URL param
rows, err := s.db.Pool.Query(r.Context(),
`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 wallet_id = $1 AND org_id = $2
ORDER BY created_at DESC LIMIT 100`, walletID, orgID)
txs, err := orm.TypedQuery[db.Transaction](s.db.ORM).
Filter("walletId =", walletID).
Filter("orgId =", orgID).
Order("-createdAt").
Limit(100).
GetAll(r.Context())
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 {
writeError(w, http.StatusInternalServerError, "scan error")
return
}
txs = append(txs, tx)
}
if txs == nil {
txs = []db.Transaction{}
txs = []*db.Transaction{}
}
writeJSON(w, http.StatusOK, txs)
}
+60 -70
View File
@@ -4,31 +4,22 @@ import (
"encoding/json"
"net/http"
"github.com/hanzoai/orm"
"github.com/luxfi/mpc/pkg/db"
)
func (s *Server) handleListWebhooks(w http.ResponseWriter, r *http.Request) {
orgID := getOrgID(r.Context())
rows, err := s.db.Pool.Query(r.Context(),
`SELECT id, org_id, url, events, enabled, created_at
FROM webhooks WHERE org_id = $1 ORDER BY created_at DESC`, orgID)
webhooks, err := orm.TypedQuery[db.Webhook](s.db.ORM).
Filter("orgId =", orgID).
Order("-createdAt").
GetAll(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "database error")
return
}
defer rows.Close()
var webhooks []db.Webhook
for rows.Next() {
var wh db.Webhook
if err := rows.Scan(&wh.ID, &wh.OrgID, &wh.URL, &wh.Events,
&wh.Enabled, &wh.CreatedAt); err != nil {
continue
}
webhooks = append(webhooks, wh)
}
if webhooks == nil {
webhooks = []db.Webhook{}
webhooks = []*db.Webhook{}
}
writeJSON(w, http.StatusOK, webhooks)
}
@@ -49,14 +40,13 @@ func (s *Server) handleCreateWebhook(w http.ResponseWriter, r *http.Request) {
return
}
var wh db.Webhook
err := s.db.Pool.QueryRow(r.Context(),
`INSERT INTO webhooks (org_id, url, secret, events)
VALUES ($1, $2, $3, $4)
RETURNING id, org_id, url, events, enabled, created_at`,
orgID, req.URL, req.Secret, req.Events).
Scan(&wh.ID, &wh.OrgID, &wh.URL, &wh.Events, &wh.Enabled, &wh.CreatedAt)
if err != nil {
wh := orm.New[db.Webhook](s.db.ORM)
wh.OrgID = orgID
wh.URL = req.URL
wh.Secret = req.Secret
wh.Events = req.Events
wh.Enabled = true
if err := wh.Create(); err != nil {
writeError(w, http.StatusInternalServerError, "failed to create webhook")
return
}
@@ -77,20 +67,25 @@ func (s *Server) handleUpdateWebhook(w http.ResponseWriter, r *http.Request) {
return
}
var wh db.Webhook
err := s.db.Pool.QueryRow(r.Context(),
`UPDATE webhooks SET
url = COALESCE($1, url),
events = COALESCE($2, events),
enabled = COALESCE($3, enabled)
WHERE id = $4 AND org_id = $5
RETURNING id, org_id, url, events, enabled, created_at`,
req.URL, req.Events, req.Enabled, whID, orgID).
Scan(&wh.ID, &wh.OrgID, &wh.URL, &wh.Events, &wh.Enabled, &wh.CreatedAt)
if err != nil {
wh, err := orm.Get[db.Webhook](s.db.ORM, whID)
if err != nil || wh.OrgID != orgID {
writeError(w, http.StatusNotFound, "webhook not found")
return
}
if req.URL != nil {
wh.URL = *req.URL
}
if len(req.Events) > 0 {
wh.Events = req.Events
}
if req.Enabled != nil {
wh.Enabled = *req.Enabled
}
if err := wh.Update(); err != nil {
writeError(w, http.StatusInternalServerError, "failed to update webhook")
return
}
writeJSON(w, http.StatusOK, wh)
}
@@ -98,12 +93,16 @@ func (s *Server) handleDeleteWebhook(w http.ResponseWriter, r *http.Request) {
orgID := getOrgID(r.Context())
whID := urlParam(r, "id")
tag, err := s.db.Pool.Exec(r.Context(),
`DELETE FROM webhooks WHERE id = $1 AND org_id = $2`, whID, orgID)
if err != nil || tag.RowsAffected() == 0 {
wh, err := orm.Get[db.Webhook](s.db.ORM, whID)
if err != nil || wh.OrgID != orgID {
writeError(w, http.StatusNotFound, "webhook not found")
return
}
if err := wh.Delete(); err != nil {
writeError(w, http.StatusInternalServerError, "failed to delete webhook")
return
}
w.WriteHeader(http.StatusNoContent)
}
@@ -111,11 +110,8 @@ func (s *Server) handleTestWebhook(w http.ResponseWriter, r *http.Request) {
orgID := getOrgID(r.Context())
whID := urlParam(r, "id")
var url, secret string
err := s.db.Pool.QueryRow(r.Context(),
`SELECT url, secret FROM webhooks WHERE id = $1 AND org_id = $2`,
whID, orgID).Scan(&url, &secret)
if err != nil {
wh, err := orm.Get[db.Webhook](s.db.ORM, whID)
if err != nil || wh.OrgID != orgID {
writeError(w, http.StatusNotFound, "webhook not found")
return
}
@@ -124,33 +120,24 @@ func (s *Server) handleTestWebhook(w http.ResponseWriter, r *http.Request) {
"event": "test",
"data": map[string]string{"message": "test webhook delivery"},
}
go deliverWebhook(url, secret, testPayload)
go deliverWebhook(wh.URL, wh.Secret, testPayload)
writeJSON(w, http.StatusOK, map[string]string{"status": "test event sent"})
}
// Whitelist handlers
func (s *Server) handleListWhitelist(w http.ResponseWriter, r *http.Request) {
orgID := getOrgID(r.Context())
rows, err := s.db.Pool.Query(r.Context(),
`SELECT id, org_id, vault_id, address, chain, label, created_by, created_at
FROM address_whitelist WHERE org_id = $1 ORDER BY created_at DESC`, orgID)
entries, err := orm.TypedQuery[db.AddressWhitelist](s.db.ORM).
Filter("orgId =", orgID).
Order("-createdAt").
GetAll(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "database error")
return
}
defer rows.Close()
var entries []db.AddressWhitelist
for rows.Next() {
var e db.AddressWhitelist
if err := rows.Scan(&e.ID, &e.OrgID, &e.VaultID, &e.Address, &e.Chain,
&e.Label, &e.CreatedBy, &e.CreatedAt); err != nil {
continue
}
entries = append(entries, e)
}
if entries == nil {
entries = []db.AddressWhitelist{}
entries = []*db.AddressWhitelist{}
}
writeJSON(w, http.StatusOK, entries)
}
@@ -174,15 +161,14 @@ func (s *Server) handleAddWhitelist(w http.ResponseWriter, r *http.Request) {
return
}
var entry db.AddressWhitelist
err := s.db.Pool.QueryRow(r.Context(),
`INSERT INTO address_whitelist (org_id, vault_id, address, chain, label, created_by)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, org_id, vault_id, address, chain, label, created_by, created_at`,
orgID, req.VaultID, req.Address, req.Chain, req.Label, userID).
Scan(&entry.ID, &entry.OrgID, &entry.VaultID, &entry.Address, &entry.Chain,
&entry.Label, &entry.CreatedBy, &entry.CreatedAt)
if err != nil {
entry := orm.New[db.AddressWhitelist](s.db.ORM)
entry.OrgID = orgID
entry.VaultID = req.VaultID
entry.Address = req.Address
entry.Chain = req.Chain
entry.Label = req.Label
entry.CreatedBy = nilIfEmpty(userID)
if err := entry.Create(); err != nil {
writeError(w, http.StatusInternalServerError, "failed to add whitelist entry")
return
}
@@ -193,11 +179,15 @@ func (s *Server) handleDeleteWhitelist(w http.ResponseWriter, r *http.Request) {
orgID := getOrgID(r.Context())
entryID := urlParam(r, "id")
tag, err := s.db.Pool.Exec(r.Context(),
`DELETE FROM address_whitelist WHERE id = $1 AND org_id = $2`, entryID, orgID)
if err != nil || tag.RowsAffected() == 0 {
entry, err := orm.Get[db.AddressWhitelist](s.db.ORM, entryID)
if err != nil || entry.OrgID != orgID {
writeError(w, http.StatusNotFound, "whitelist entry not found")
return
}
if err := entry.Delete(); err != nil {
writeError(w, http.StatusInternalServerError, "failed to delete whitelist entry")
return
}
w.WriteHeader(http.StatusNoContent)
}
+8 -4
View File
@@ -7,6 +7,8 @@ import (
"strings"
"github.com/go-chi/chi/v5"
"github.com/hanzoai/orm"
"github.com/luxfi/mpc/pkg/db"
)
type contextKey string
@@ -120,10 +122,12 @@ func (s *Server) writeAuditLog(ctx context.Context, r *http.Request) {
ip = strings.Split(xff, ",")[0]
}
_, _ = s.db.Pool.Exec(context.Background(),
`INSERT INTO audit_log (org_id, user_id, action, ip_address) VALUES ($1, $2, $3, $4)`,
orgID, nilIfEmpty(userID), action, ip,
)
entry := orm.New[db.AuditEntry](s.db.ORM)
entry.OrgID = orgID
entry.UserID = nilIfEmpty(userID)
entry.Action = action
entry.IPAddress = nilIfEmpty(ip)
entry.Create()
}
func nilIfEmpty(s string) *string {
+17 -23
View File
@@ -6,6 +6,7 @@ import (
"math/big"
"sort"
"github.com/hanzoai/orm"
"github.com/luxfi/mpc/pkg/db"
)
@@ -21,7 +22,7 @@ type PolicyConditions struct {
AllowedAddresses []string `json:"allowed_addresses,omitempty"`
}
func evaluateTransaction(amount, chain, toAddress string, policies []db.Policy) PolicyDecision {
func evaluateTransaction(amount, chain, toAddress string, policies []*db.Policy) PolicyDecision {
// Sort by priority descending
sort.Slice(policies, func(i, j int) bool {
return policies[i].Priority > policies[j].Priority
@@ -94,7 +95,6 @@ func matchesConditions(amount, chain, toAddress string, cond PolicyConditions) b
max, ok1 := new(big.Float).SetString(cond.MaxAmount)
val, ok2 := new(big.Float).SetString(amount)
if ok1 && ok2 && val.Cmp(max) > 0 {
// Amount exceeds max — this condition triggers
return true
}
}
@@ -102,33 +102,27 @@ func matchesConditions(amount, chain, toAddress string, cond PolicyConditions) b
return true
}
func (s *Server) loadPolicies(ctx context.Context, orgID string, vaultID *string) ([]db.Policy, error) {
query := `SELECT id, org_id, vault_id, name, priority, action, conditions,
required_approvers, approver_roles, enabled, created_at
FROM policies WHERE org_id = $1 AND enabled = true`
args := []interface{}{orgID}
func (s *Server) loadPolicies(ctx context.Context, orgID string, vaultID *string) ([]*db.Policy, error) {
q := orm.TypedQuery[db.Policy](s.db.ORM).
Filter("orgId =", orgID).
Filter("enabled =", true).
Order("-priority")
if vaultID != nil {
query += " AND (vault_id IS NULL OR vault_id = $2)"
args = append(args, *vaultID)
}
query += " ORDER BY priority DESC"
rows, err := s.db.Pool.Query(ctx, query, args...)
policies, err := q.GetAll(ctx)
if err != nil {
return nil, err
}
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
// Filter by vaultID if provided (include policies with no vaultID or matching vaultID)
if vaultID != nil {
var filtered []*db.Policy
for _, p := range policies {
if p.VaultID == nil || *p.VaultID == *vaultID {
filtered = append(filtered, p)
}
}
policies = append(policies, p)
return filtered, nil
}
return policies, nil
}
+8 -8
View File
@@ -17,7 +17,7 @@ func mustMarshal(t *testing.T, v interface{}) []byte {
}
func TestPolicyDeny(t *testing.T) {
policies := []db.Policy{
policies := []*db.Policy{
{
Name: "block-all",
Priority: 10,
@@ -40,7 +40,7 @@ func TestPolicyDeny(t *testing.T) {
}
func TestPolicyApprove(t *testing.T) {
policies := []db.Policy{
policies := []*db.Policy{
{
Name: "auto-approve-small",
Priority: 10,
@@ -63,7 +63,7 @@ func TestPolicyApprove(t *testing.T) {
}
func TestPolicyRequireApproval(t *testing.T) {
policies := []db.Policy{
policies := []*db.Policy{
{
Name: "require-two-admins",
Priority: 10,
@@ -86,7 +86,7 @@ func TestPolicyRequireApproval(t *testing.T) {
func TestPolicyMaxAmount(t *testing.T) {
// Policy: deny transactions exceeding 100
policies := []db.Policy{
policies := []*db.Policy{
{
Name: "high-value-deny",
Priority: 10,
@@ -114,7 +114,7 @@ func TestPolicyMaxAmount(t *testing.T) {
}
func TestPolicyChainFilter(t *testing.T) {
policies := []db.Policy{
policies := []*db.Policy{
{
Name: "eth-only-approve",
Priority: 10,
@@ -143,7 +143,7 @@ func TestPolicyChainFilter(t *testing.T) {
}
func TestPolicyPriority(t *testing.T) {
policies := []db.Policy{
policies := []*db.Policy{
{
Name: "low-priority-approve",
Priority: 1,
@@ -187,7 +187,7 @@ func TestDefaultPolicy(t *testing.T) {
}
func TestDisabledPolicySkipped(t *testing.T) {
policies := []db.Policy{
policies := []*db.Policy{
{
Name: "disabled-deny",
Priority: 100,
@@ -209,7 +209,7 @@ func TestDisabledPolicySkipped(t *testing.T) {
}
func TestPolicyBadConditionsJSON(t *testing.T) {
policies := []db.Policy{
policies := []*db.Policy{
{
Name: "bad-json",
Priority: 100,
+47 -44
View File
@@ -4,6 +4,8 @@ import (
"context"
"time"
"github.com/hanzoai/orm"
"github.com/luxfi/mpc/pkg/db"
"github.com/luxfi/mpc/pkg/logger"
)
@@ -24,79 +26,80 @@ func (s *Server) StartScheduler(ctx context.Context) {
}
func (s *Server) processDueSubscriptions(ctx context.Context) {
rows, err := s.db.Pool.Query(ctx,
`SELECT id, org_id, wallet_id, recipient_address, chain, token, amount,
interval, max_retries, retry_count, require_balance
FROM subscriptions
WHERE status = 'active' AND next_payment_at <= NOW()
LIMIT 50`)
now := time.Now()
subs, err := orm.TypedQuery[db.Subscription](s.db.ORM).
Filter("status =", "active").
Limit(50).
GetAll(ctx)
if err != nil {
logger.Error("scheduler: failed to query due subscriptions", err)
return
}
defer rows.Close()
for rows.Next() {
var (
id, orgID, walletID, recipientAddress, chain, amount, interval string
token *string
maxRetries, retryCount int
requireBalance bool
)
if err := rows.Scan(&id, &orgID, &walletID, &recipientAddress, &chain,
&token, &amount, &interval, &maxRetries, &retryCount, &requireBalance); err != nil {
for _, sub := range subs {
if sub.NextPaymentAt.After(now) {
continue
}
s.processSubscriptionPayment(ctx, id, orgID, walletID, recipientAddress,
chain, token, amount, interval, maxRetries, retryCount, requireBalance)
s.processSubscriptionPayment(ctx, sub)
}
}
func (s *Server) processSubscriptionPayment(ctx context.Context,
subID, orgID, walletID, recipientAddress, chain string,
token *string, amount, interval string,
maxRetries, retryCount int, requireBalance bool) {
func (s *Server) processSubscriptionPayment(ctx context.Context, sub *db.Subscription) {
orgID := sub.OrgID
subID := sub.Id()
walletID := ""
if sub.WalletID != nil {
walletID = *sub.WalletID
}
tokenStr := ""
if token != nil {
tokenStr = *token
if sub.Token != nil {
tokenStr = *sub.Token
}
// Create transaction
var txID string
err := s.db.Pool.QueryRow(ctx,
`INSERT INTO transactions (org_id, wallet_id, tx_type, chain, to_address, amount, token, status)
VALUES ($1, $2, 'subscription_payment', $3, $4, $5, $6, 'approved')
RETURNING id`,
orgID, walletID, chain, recipientAddress, amount, nilIfEmpty(tokenStr)).Scan(&txID)
if err != nil {
tx := orm.New[db.Transaction](s.db.ORM)
tx.OrgID = orgID
if walletID != "" {
tx.WalletID = &walletID
}
tx.TxType = "subscription_payment"
tx.Chain = sub.Chain
tx.ToAddress = nilIfEmpty(sub.RecipientAddress)
tx.Amount = nilIfEmpty(sub.Amount)
tx.Token = nilIfEmpty(tokenStr)
tx.Status = "approved"
if err := tx.Create(); err != nil {
logger.Error("scheduler: failed to create payment tx", err)
// Retry logic
if retryCount < maxRetries {
s.db.Pool.Exec(ctx,
`UPDATE subscriptions SET retry_count = retry_count + 1,
next_payment_at = NOW() + interval '1 hour'
WHERE id = $1`, subID)
if sub.RetryCount < sub.MaxRetries {
sub.RetryCount++
nextRetry := time.Now().Add(time.Hour)
sub.NextPaymentAt = nextRetry
sub.Update()
s.fireWebhook(ctx, orgID, "subscription.insufficient_funds", map[string]string{"subscription_id": subID})
} else {
s.db.Pool.Exec(ctx,
`UPDATE subscriptions SET status = 'failed' WHERE id = $1`, subID)
sub.Status = "failed"
sub.Update()
s.fireWebhook(ctx, orgID, "subscription.failed", map[string]string{"subscription_id": subID})
}
return
}
txID := tx.Id()
// Sign and broadcast
s.signAndBroadcast(txID, orgID)
// Advance next payment
nextPayment := computeNextPayment(interval)
nextPayment := computeNextPayment(sub.Interval)
now := time.Now()
s.db.Pool.Exec(ctx,
`UPDATE subscriptions SET
next_payment_at = $1, last_payment_at = $2, last_tx_id = $3, retry_count = 0
WHERE id = $4`, nextPayment, now, txID, subID)
sub.NextPaymentAt = nextPayment
sub.LastPaymentAt = &now
sub.LastTxID = &txID
sub.RetryCount = 0
sub.Update()
s.fireWebhook(ctx, orgID, "subscription.paid", map[string]string{
"subscription_id": subID,
+2 -2
View File
@@ -44,14 +44,14 @@ type ClusterStatus struct {
}
type Server struct {
db *db.DB
db *db.Database
mpc MPCBackend
jwtSecret []byte
router chi.Router
server *http.Server
}
func NewServer(database *db.DB, mpcBackend MPCBackend, jwtSecret string, listenAddr string) *Server {
func NewServer(database *db.Database, mpcBackend MPCBackend, jwtSecret string, listenAddr string) *Server {
s := &Server{
db: database,
mpc: mpcBackend,
+14 -10
View File
@@ -9,17 +9,19 @@ import (
"encoding/json"
"net/http"
"time"
"github.com/hanzoai/orm"
"github.com/luxfi/mpc/pkg/db"
)
func (s *Server) fireWebhook(ctx context.Context, orgID, event string, data interface{}) {
rows, err := s.db.Pool.Query(ctx,
`SELECT url, secret FROM webhooks
WHERE org_id = $1 AND enabled = true AND $2 = ANY(events)`,
orgID, event)
webhooks, err := orm.TypedQuery[db.Webhook](s.db.ORM).
Filter("orgId =", orgID).
Filter("enabled =", true).
GetAll(ctx)
if err != nil {
return
}
defer rows.Close()
payload := map[string]interface{}{
"event": event,
@@ -27,12 +29,14 @@ func (s *Server) fireWebhook(ctx context.Context, orgID, event string, data inte
"timestamp": time.Now().UTC().Format(time.RFC3339),
}
for rows.Next() {
var url, secret string
if err := rows.Scan(&url, &secret); err != nil {
continue
for _, wh := range webhooks {
// Only fire for webhooks that include this event
for _, e := range wh.Events {
if e == event {
go deliverWebhook(wh.URL, wh.Secret, payload)
break
}
}
go deliverWebhook(url, secret, payload)
}
}
+58
View File
@@ -0,0 +1,58 @@
package db
import (
"github.com/jackc/pgx/v5/pgxpool"
kv "github.com/hanzoai/kv-go/v9"
"github.com/hanzoai/orm"
ormdb "github.com/hanzoai/orm/db"
)
// Database holds the ORM entity store and the KV cache/session client.
type Database struct {
ORM orm.DB
KV kv.UniversalClient
pool *pgxpool.Pool // underlying pool for raw SQL fallback
}
// New creates a Database wired to PostgreSQL (via ORM) and Valkey/Redis (via KV).
func New(postgresURL, kvURL string) (*Database, error) {
sqlDB, err := ormdb.NewSQLDB(&ormdb.SQLConfig{
DSN: postgresURL,
MaxConns: 20,
MinConns: 2,
})
if err != nil {
return nil, err
}
ormInstance := orm.AdaptDB(sqlDB)
var kvClient kv.UniversalClient
if kvURL != "" {
kvClient = kv.NewUniversalClient(&kv.UniversalOptions{
Addrs: []string{kvURL},
})
}
return &Database{
ORM: ormInstance,
KV: kvClient,
pool: sqlDB.Pool(),
}, nil
}
// Pool returns the underlying pgxpool.Pool for raw SQL operations.
// Use this only for queries that have no ORM equivalent.
func (d *Database) Pool() *pgxpool.Pool {
return d.pool
}
// Close releases all database resources.
func (d *Database) Close() {
if d.ORM != nil {
d.ORM.Close()
}
if d.KV != nil {
d.KV.Close()
}
}
-223
View File
@@ -1,223 +0,0 @@
-- MPC Dashboard Schema
-- Organizations (top-level tenant)
CREATE TABLE IF NOT EXISTS organizations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Users
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID REFERENCES organizations(id) ON DELETE CASCADE,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'viewer',
mfa_secret TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- API Keys (per-org, for programmatic access)
CREATE TABLE IF NOT EXISTS api_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID REFERENCES organizations(id) ON DELETE CASCADE,
name TEXT NOT NULL,
key_hash TEXT NOT NULL,
key_prefix TEXT NOT NULL,
permissions TEXT[] DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW(),
expires_at TIMESTAMPTZ,
last_used_at TIMESTAMPTZ
);
-- Vaults (logical grouping of wallets)
CREATE TABLE IF NOT EXISTS vaults (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID REFERENCES organizations(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT,
app_id TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Wallets (MPC-generated keys)
CREATE TABLE IF NOT EXISTS wallets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
vault_id UUID REFERENCES vaults(id) ON DELETE CASCADE,
org_id UUID REFERENCES organizations(id) ON DELETE CASCADE,
wallet_id TEXT UNIQUE NOT NULL,
name TEXT,
key_type TEXT NOT NULL,
ecdsa_pubkey TEXT,
eddsa_pubkey TEXT,
eth_address TEXT,
btc_address TEXT,
sol_address TEXT,
threshold INT NOT NULL,
participants TEXT[] NOT NULL,
version INT DEFAULT 1,
status TEXT DEFAULT 'active',
created_by UUID REFERENCES users(id),
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Transactions
CREATE TABLE IF NOT EXISTS transactions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID REFERENCES organizations(id) ON DELETE CASCADE,
wallet_id UUID REFERENCES wallets(id) ON DELETE SET NULL,
tx_type TEXT NOT NULL,
chain TEXT NOT NULL,
to_address TEXT,
amount TEXT,
token TEXT,
tx_hash TEXT,
raw_tx BYTEA,
signature_r TEXT,
signature_s TEXT,
signature_eddsa TEXT,
status TEXT DEFAULT 'pending',
initiated_by UUID REFERENCES users(id),
approved_by UUID[],
rejected_by UUID,
rejection_reason TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
signed_at TIMESTAMPTZ,
broadcast_at TIMESTAMPTZ
);
-- Policy Rules
CREATE TABLE IF NOT EXISTS policies (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID REFERENCES organizations(id) ON DELETE CASCADE,
vault_id UUID REFERENCES vaults(id) ON DELETE CASCADE,
name TEXT NOT NULL,
priority INT DEFAULT 0,
action TEXT NOT NULL,
conditions JSONB NOT NULL,
required_approvers INT DEFAULT 1,
approver_roles TEXT[] DEFAULT '{admin}',
enabled BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Whitelisted Addresses
CREATE TABLE IF NOT EXISTS address_whitelist (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID REFERENCES organizations(id) ON DELETE CASCADE,
vault_id UUID REFERENCES vaults(id) ON DELETE SET NULL,
address TEXT NOT NULL,
chain TEXT NOT NULL,
label TEXT,
created_by UUID REFERENCES users(id),
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Audit Log
CREATE TABLE IF NOT EXISTS audit_log (
id BIGSERIAL PRIMARY KEY,
org_id UUID REFERENCES organizations(id) ON DELETE CASCADE,
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
action TEXT NOT NULL,
resource_type TEXT,
resource_id TEXT,
details JSONB,
ip_address TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Webhooks
CREATE TABLE IF NOT EXISTS webhooks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID REFERENCES organizations(id) ON DELETE CASCADE,
url TEXT NOT NULL,
secret TEXT NOT NULL,
events TEXT[] NOT NULL,
enabled BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Subscriptions (recurring payments)
CREATE TABLE IF NOT EXISTS subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID REFERENCES organizations(id) ON DELETE CASCADE,
wallet_id UUID REFERENCES wallets(id) ON DELETE SET NULL,
name TEXT NOT NULL,
provider_name TEXT,
recipient_address TEXT NOT NULL,
chain TEXT NOT NULL,
token TEXT,
amount TEXT NOT NULL,
currency TEXT DEFAULT 'USD',
interval TEXT NOT NULL,
next_payment_at TIMESTAMPTZ NOT NULL,
last_payment_at TIMESTAMPTZ,
last_tx_id UUID REFERENCES transactions(id) ON DELETE SET NULL,
status TEXT DEFAULT 'active',
max_retries INT DEFAULT 3,
retry_count INT DEFAULT 0,
require_balance BOOLEAN DEFAULT true,
created_by UUID REFERENCES users(id),
cancelled_by UUID REFERENCES users(id),
created_at TIMESTAMPTZ DEFAULT NOW(),
cancelled_at TIMESTAMPTZ
);
-- Payment Requests (inbound)
CREATE TABLE IF NOT EXISTS payment_requests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID REFERENCES organizations(id) ON DELETE CASCADE,
wallet_id UUID REFERENCES wallets(id) ON DELETE SET NULL,
request_token TEXT UNIQUE NOT NULL,
merchant_name TEXT,
recipient_address TEXT NOT NULL,
chain TEXT NOT NULL,
token TEXT,
amount TEXT NOT NULL,
memo TEXT,
status TEXT DEFAULT 'pending',
expires_at TIMESTAMPTZ,
paid_tx_id UUID REFERENCES transactions(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Smart Contract Wallets (Safe/ERC-4337)
CREATE TABLE IF NOT EXISTS smart_wallets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
wallet_id UUID REFERENCES wallets(id) ON DELETE CASCADE,
org_id UUID REFERENCES organizations(id) ON DELETE CASCADE,
chain TEXT NOT NULL,
contract_address TEXT NOT NULL,
wallet_type TEXT NOT NULL,
factory_address TEXT,
entrypoint_address TEXT,
salt TEXT,
owners TEXT[] NOT NULL,
threshold INT NOT NULL,
status TEXT DEFAULT 'active',
deployed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Indexes
CREATE INDEX IF NOT EXISTS idx_users_org ON users(org_id);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE INDEX IF NOT EXISTS idx_api_keys_org ON api_keys(org_id);
CREATE INDEX IF NOT EXISTS idx_api_keys_prefix ON api_keys(key_prefix);
CREATE INDEX IF NOT EXISTS idx_vaults_org ON vaults(org_id);
CREATE INDEX IF NOT EXISTS idx_wallets_vault ON wallets(vault_id);
CREATE INDEX IF NOT EXISTS idx_wallets_org ON wallets(org_id);
CREATE INDEX IF NOT EXISTS idx_wallets_wallet_id ON wallets(wallet_id);
CREATE INDEX IF NOT EXISTS idx_transactions_org ON transactions(org_id);
CREATE INDEX IF NOT EXISTS idx_transactions_wallet ON transactions(wallet_id);
CREATE INDEX IF NOT EXISTS idx_transactions_status ON transactions(status);
CREATE INDEX IF NOT EXISTS idx_policies_org ON policies(org_id);
CREATE INDEX IF NOT EXISTS idx_policies_vault ON policies(vault_id);
CREATE INDEX IF NOT EXISTS idx_audit_org ON audit_log(org_id);
CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_log(created_at);
CREATE INDEX IF NOT EXISTS idx_webhooks_org ON webhooks(org_id);
CREATE INDEX IF NOT EXISTS idx_subscriptions_org ON subscriptions(org_id);
CREATE INDEX IF NOT EXISTS idx_subscriptions_next ON subscriptions(next_payment_at) WHERE status = 'active';
CREATE INDEX IF NOT EXISTS idx_payment_requests_token ON payment_requests(request_token);
CREATE INDEX IF NOT EXISTS idx_smart_wallets_wallet ON smart_wallets(wallet_id);
+177 -149
View File
@@ -2,192 +2,220 @@ package db
import (
"time"
"github.com/hanzoai/orm"
)
// Organization represents a tenant organization.
type Organization struct {
ID string `json:"id" db:"id"`
Name string `json:"name" db:"name"`
Slug string `json:"slug" db:"slug"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
orm.Model[Organization]
Name string `json:"name"`
Slug string `json:"slug"`
}
func init() { orm.Register[Organization]("organization") }
// User represents an authenticated user within an organization.
type User struct {
ID string `json:"id" db:"id"`
OrgID string `json:"org_id" db:"org_id"`
Email string `json:"email" db:"email"`
PasswordHash string `json:"-" db:"password_hash"`
Role string `json:"role" db:"role"`
MFASecret *string `json:"-" db:"mfa_secret"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
orm.Model[User]
OrgID string `json:"orgId"`
Email string `json:"email"`
PasswordHash string `json:"passwordHash"`
Role string `json:"role"`
MFASecret *string `json:"mfaSecret,omitempty"`
}
func init() { orm.Register[User]("user") }
// APIKey represents an API key for programmatic access.
type APIKey struct {
ID string `json:"id" db:"id"`
OrgID string `json:"org_id" db:"org_id"`
Name string `json:"name" db:"name"`
KeyHash string `json:"-" db:"key_hash"`
KeyPrefix string `json:"key_prefix" db:"key_prefix"`
Permissions []string `json:"permissions" db:"permissions"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
ExpiresAt *time.Time `json:"expires_at,omitempty" db:"expires_at"`
LastUsedAt *time.Time `json:"last_used_at,omitempty" db:"last_used_at"`
orm.Model[APIKey]
OrgID string `json:"orgId"`
Name string `json:"name"`
KeyHash string `json:"keyHash"`
KeyPrefix string `json:"keyPrefix"`
Permissions []string `json:"permissions"`
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
LastUsedAt *time.Time `json:"lastUsedAt,omitempty"`
}
func init() { orm.Register[APIKey]("api-key") }
// Vault is a logical grouping of wallets.
type Vault struct {
ID string `json:"id" db:"id"`
OrgID string `json:"org_id" db:"org_id"`
Name string `json:"name" db:"name"`
Description *string `json:"description,omitempty" db:"description"`
AppID *string `json:"app_id,omitempty" db:"app_id"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
orm.Model[Vault]
OrgID string `json:"orgId"`
Name string `json:"name"`
Description *string `json:"description,omitempty"`
AppID *string `json:"appId,omitempty"`
}
func init() { orm.Register[Vault]("vault") }
// Wallet holds an MPC key share group.
type Wallet struct {
ID string `json:"id" db:"id"`
VaultID string `json:"vault_id" db:"vault_id"`
OrgID string `json:"org_id" db:"org_id"`
WalletID string `json:"wallet_id" db:"wallet_id"`
Name *string `json:"name,omitempty" db:"name"`
KeyType string `json:"key_type" db:"key_type"`
ECDSAPubkey *string `json:"ecdsa_pubkey,omitempty" db:"ecdsa_pubkey"`
EDDSAPubkey *string `json:"eddsa_pubkey,omitempty" db:"eddsa_pubkey"`
EthAddress *string `json:"eth_address,omitempty" db:"eth_address"`
BtcAddress *string `json:"btc_address,omitempty" db:"btc_address"`
SolAddress *string `json:"sol_address,omitempty" db:"sol_address"`
Threshold int `json:"threshold" db:"threshold"`
Participants []string `json:"participants" db:"participants"`
Version int `json:"version" db:"version"`
Status string `json:"status" db:"status"`
CreatedBy *string `json:"created_by,omitempty" db:"created_by"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
orm.Model[Wallet]
VaultID string `json:"vaultId"`
OrgID string `json:"orgId"`
WalletID string `json:"walletId"`
Name *string `json:"name,omitempty"`
KeyType string `json:"keyType"`
ECDSAPubkey *string `json:"ecdsaPubkey,omitempty"`
EDDSAPubkey *string `json:"eddsaPubkey,omitempty"`
EthAddress *string `json:"ethAddress,omitempty"`
BtcAddress *string `json:"btcAddress,omitempty"`
SolAddress *string `json:"solAddress,omitempty"`
Threshold int `json:"threshold"`
Participants []string `json:"participants"`
Version int `json:"version"`
Status string `json:"status"`
CreatedBy *string `json:"createdBy,omitempty"`
}
func init() { orm.Register[Wallet]("wallet") }
// Transaction is a blockchain transaction record.
type Transaction struct {
ID string `json:"id" db:"id"`
OrgID string `json:"org_id" db:"org_id"`
WalletID *string `json:"wallet_id,omitempty" db:"wallet_id"`
TxType string `json:"tx_type" db:"tx_type"`
Chain string `json:"chain" db:"chain"`
ToAddress *string `json:"to_address,omitempty" db:"to_address"`
Amount *string `json:"amount,omitempty" db:"amount"`
Token *string `json:"token,omitempty" db:"token"`
TxHash *string `json:"tx_hash,omitempty" db:"tx_hash"`
RawTx []byte `json:"-" db:"raw_tx"`
SignatureR *string `json:"signature_r,omitempty" db:"signature_r"`
SignatureS *string `json:"signature_s,omitempty" db:"signature_s"`
SignatureEdDSA *string `json:"signature_eddsa,omitempty" db:"signature_eddsa"`
Status string `json:"status" db:"status"`
InitiatedBy *string `json:"initiated_by,omitempty" db:"initiated_by"`
ApprovedBy []string `json:"approved_by,omitempty" db:"approved_by"`
RejectedBy *string `json:"rejected_by,omitempty" db:"rejected_by"`
RejectionReason *string `json:"rejection_reason,omitempty" db:"rejection_reason"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
SignedAt *time.Time `json:"signed_at,omitempty" db:"signed_at"`
BroadcastAt *time.Time `json:"broadcast_at,omitempty" db:"broadcast_at"`
orm.Model[Transaction]
OrgID string `json:"orgId"`
WalletID *string `json:"walletId,omitempty"`
TxType string `json:"txType"`
Chain string `json:"chain"`
ToAddress *string `json:"toAddress,omitempty"`
Amount *string `json:"amount,omitempty"`
Token *string `json:"token,omitempty"`
TxHash *string `json:"txHash,omitempty"`
RawTx []byte `json:"rawTx,omitempty"`
SignatureR *string `json:"signatureR,omitempty"`
SignatureS *string `json:"signatureS,omitempty"`
SignatureEdDSA *string `json:"signatureEdDSA,omitempty"`
Status string `json:"status"`
InitiatedBy *string `json:"initiatedBy,omitempty"`
ApprovedBy []string `json:"approvedBy,omitempty"`
RejectedBy *string `json:"rejectedBy,omitempty"`
RejectionReason *string `json:"rejectionReason,omitempty"`
SignedAt *time.Time `json:"signedAt,omitempty"`
BroadcastAt *time.Time `json:"broadcastAt,omitempty"`
}
func init() { orm.Register[Transaction]("transaction") }
// Policy is a signing policy rule.
type Policy struct {
ID string `json:"id" db:"id"`
OrgID string `json:"org_id" db:"org_id"`
VaultID *string `json:"vault_id,omitempty" db:"vault_id"`
Name string `json:"name" db:"name"`
Priority int `json:"priority" db:"priority"`
Action string `json:"action" db:"action"`
Conditions []byte `json:"conditions" db:"conditions"`
RequiredApprovers int `json:"required_approvers" db:"required_approvers"`
ApproverRoles []string `json:"approver_roles" db:"approver_roles"`
Enabled bool `json:"enabled" db:"enabled"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
orm.Model[Policy]
OrgID string `json:"orgId"`
VaultID *string `json:"vaultId,omitempty"`
Name string `json:"name"`
Priority int `json:"priority"`
Action string `json:"action"`
Conditions []byte `json:"conditions"`
RequiredApprovers int `json:"requiredApprovers"`
ApproverRoles []string `json:"approverRoles"`
Enabled bool `json:"enabled"`
}
func init() { orm.Register[Policy]("policy") }
// AddressWhitelist is an approved destination address.
type AddressWhitelist struct {
ID string `json:"id" db:"id"`
OrgID string `json:"org_id" db:"org_id"`
VaultID *string `json:"vault_id,omitempty" db:"vault_id"`
Address string `json:"address" db:"address"`
Chain string `json:"chain" db:"chain"`
Label *string `json:"label,omitempty" db:"label"`
CreatedBy *string `json:"created_by,omitempty" db:"created_by"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
orm.Model[AddressWhitelist]
OrgID string `json:"orgId"`
VaultID *string `json:"vaultId,omitempty"`
Address string `json:"address"`
Chain string `json:"chain"`
Label *string `json:"label,omitempty"`
CreatedBy *string `json:"createdBy,omitempty"`
}
func init() { orm.Register[AddressWhitelist]("address-whitelist") }
// AuditEntry is an immutable audit log record.
type AuditEntry struct {
ID int64 `json:"id" db:"id"`
OrgID string `json:"org_id" db:"org_id"`
UserID *string `json:"user_id,omitempty" db:"user_id"`
Action string `json:"action" db:"action"`
ResourceType *string `json:"resource_type,omitempty" db:"resource_type"`
ResourceID *string `json:"resource_id,omitempty" db:"resource_id"`
Details []byte `json:"details,omitempty" db:"details"`
IPAddress *string `json:"ip_address,omitempty" db:"ip_address"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
orm.Model[AuditEntry]
OrgID string `json:"orgId"`
UserID *string `json:"userId,omitempty"`
Action string `json:"action"`
ResourceType *string `json:"resourceType,omitempty"`
ResourceID *string `json:"resourceId,omitempty"`
Details []byte `json:"details,omitempty"`
IPAddress *string `json:"ipAddress,omitempty"`
}
func init() { orm.Register[AuditEntry]("audit-entry") }
// Webhook is an outbound event delivery endpoint.
type Webhook struct {
ID string `json:"id" db:"id"`
OrgID string `json:"org_id" db:"org_id"`
URL string `json:"url" db:"url"`
Secret string `json:"-" db:"secret"`
Events []string `json:"events" db:"events"`
Enabled bool `json:"enabled" db:"enabled"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
orm.Model[Webhook]
OrgID string `json:"orgId"`
URL string `json:"url"`
Secret string `json:"secret"`
Events []string `json:"events"`
Enabled bool `json:"enabled"`
}
func init() { orm.Register[Webhook]("webhook") }
// Subscription is a recurring payment schedule.
type Subscription struct {
ID string `json:"id" db:"id"`
OrgID string `json:"org_id" db:"org_id"`
WalletID *string `json:"wallet_id,omitempty" db:"wallet_id"`
Name string `json:"name" db:"name"`
ProviderName *string `json:"provider_name,omitempty" db:"provider_name"`
RecipientAddress string `json:"recipient_address" db:"recipient_address"`
Chain string `json:"chain" db:"chain"`
Token *string `json:"token,omitempty" db:"token"`
Amount string `json:"amount" db:"amount"`
Currency string `json:"currency" db:"currency"`
Interval string `json:"interval" db:"interval"`
NextPaymentAt time.Time `json:"next_payment_at" db:"next_payment_at"`
LastPaymentAt *time.Time `json:"last_payment_at,omitempty" db:"last_payment_at"`
LastTxID *string `json:"last_tx_id,omitempty" db:"last_tx_id"`
Status string `json:"status" db:"status"`
MaxRetries int `json:"max_retries" db:"max_retries"`
RetryCount int `json:"retry_count" db:"retry_count"`
RequireBalance bool `json:"require_balance" db:"require_balance"`
CreatedBy *string `json:"created_by,omitempty" db:"created_by"`
CancelledBy *string `json:"cancelled_by,omitempty" db:"cancelled_by"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
CancelledAt *time.Time `json:"cancelled_at,omitempty" db:"cancelled_at"`
orm.Model[Subscription]
OrgID string `json:"orgId"`
WalletID *string `json:"walletId,omitempty"`
Name string `json:"name"`
ProviderName *string `json:"providerName,omitempty"`
RecipientAddress string `json:"recipientAddress"`
Chain string `json:"chain"`
Token *string `json:"token,omitempty"`
Amount string `json:"amount"`
Currency string `json:"currency"`
Interval string `json:"interval"`
NextPaymentAt time.Time `json:"nextPaymentAt"`
LastPaymentAt *time.Time `json:"lastPaymentAt,omitempty"`
LastTxID *string `json:"lastTxId,omitempty"`
Status string `json:"status"`
MaxRetries int `json:"maxRetries"`
RetryCount int `json:"retryCount"`
RequireBalance bool `json:"requireBalance"`
CreatedBy *string `json:"createdBy,omitempty"`
CancelledBy *string `json:"cancelledBy,omitempty"`
CancelledAt *time.Time `json:"cancelledAt,omitempty"`
}
func init() { orm.Register[Subscription]("subscription") }
// PaymentRequest is a one-time payment link.
type PaymentRequest struct {
ID string `json:"id" db:"id"`
OrgID string `json:"org_id" db:"org_id"`
WalletID *string `json:"wallet_id,omitempty" db:"wallet_id"`
RequestToken string `json:"request_token" db:"request_token"`
MerchantName *string `json:"merchant_name,omitempty" db:"merchant_name"`
RecipientAddress string `json:"recipient_address" db:"recipient_address"`
Chain string `json:"chain" db:"chain"`
Token *string `json:"token,omitempty" db:"token"`
Amount string `json:"amount" db:"amount"`
Memo *string `json:"memo,omitempty" db:"memo"`
Status string `json:"status" db:"status"`
ExpiresAt *time.Time `json:"expires_at,omitempty" db:"expires_at"`
PaidTxID *string `json:"paid_tx_id,omitempty" db:"paid_tx_id"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
orm.Model[PaymentRequest]
OrgID string `json:"orgId"`
WalletID *string `json:"walletId,omitempty"`
RequestToken string `json:"requestToken"`
MerchantName *string `json:"merchantName,omitempty"`
RecipientAddress string `json:"recipientAddress"`
Chain string `json:"chain"`
Token *string `json:"token,omitempty"`
Amount string `json:"amount"`
Memo *string `json:"memo,omitempty"`
Status string `json:"status"`
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
PaidTxID *string `json:"paidTxId,omitempty"`
}
func init() { orm.Register[PaymentRequest]("payment-request") }
// SmartWallet is an on-chain smart contract wallet (Safe/ERC-4337).
type SmartWallet struct {
ID string `json:"id" db:"id"`
WalletID string `json:"wallet_id" db:"wallet_id"`
OrgID string `json:"org_id" db:"org_id"`
Chain string `json:"chain" db:"chain"`
ContractAddress string `json:"contract_address" db:"contract_address"`
WalletType string `json:"wallet_type" db:"wallet_type"`
FactoryAddress *string `json:"factory_address,omitempty" db:"factory_address"`
EntrypointAddress *string `json:"entrypoint_address,omitempty" db:"entrypoint_address"`
Salt *string `json:"salt,omitempty" db:"salt"`
Owners []string `json:"owners" db:"owners"`
Threshold int `json:"threshold" db:"threshold"`
Status string `json:"status" db:"status"`
DeployedAt *time.Time `json:"deployed_at,omitempty" db:"deployed_at"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
orm.Model[SmartWallet]
WalletID string `json:"walletId"`
OrgID string `json:"orgId"`
Chain string `json:"chain"`
ContractAddress string `json:"contractAddress"`
WalletType string `json:"walletType"`
FactoryAddress *string `json:"factoryAddress,omitempty"`
EntrypointAddress *string `json:"entrypointAddress,omitempty"`
Salt *string `json:"salt,omitempty"`
Owners []string `json:"owners"`
Threshold int `json:"threshold"`
Status string `json:"status"`
DeployedAt *time.Time `json:"deployedAt,omitempty"`
}
func init() { orm.Register[SmartWallet]("smart-wallet") }
-101
View File
@@ -1,101 +0,0 @@
package db
import (
"context"
"embed"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
)
//go:embed migrations/*.sql
var migrations embed.FS
type DB struct {
Pool *pgxpool.Pool
}
func New(ctx context.Context, databaseURL string) (*DB, error) {
config, err := pgxpool.ParseConfig(databaseURL)
if err != nil {
return nil, fmt.Errorf("parse database url: %w", err)
}
config.MaxConns = 20
config.MinConns = 2
pool, err := pgxpool.NewWithConfig(ctx, config)
if err != nil {
return nil, fmt.Errorf("create pool: %w", err)
}
if err := pool.Ping(ctx); err != nil {
pool.Close()
return nil, fmt.Errorf("ping database: %w", err)
}
return &DB{Pool: pool}, nil
}
func (d *DB) Migrate(ctx context.Context) error {
// Create migrations tracking table
_, err := d.Pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY,
applied_at TIMESTAMPTZ DEFAULT NOW()
)
`)
if err != nil {
return fmt.Errorf("create migrations table: %w", err)
}
entries, err := migrations.ReadDir("migrations")
if err != nil {
return fmt.Errorf("read migrations: %w", err)
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
var applied bool
err := d.Pool.QueryRow(ctx, "SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE version=$1)", name).Scan(&applied)
if err != nil {
return fmt.Errorf("check migration %s: %w", name, err)
}
if applied {
continue
}
sql, err := migrations.ReadFile("migrations/" + name)
if err != nil {
return fmt.Errorf("read migration %s: %w", name, err)
}
tx, err := d.Pool.Begin(ctx)
if err != nil {
return fmt.Errorf("begin tx for %s: %w", name, err)
}
if _, err := tx.Exec(ctx, string(sql)); err != nil {
tx.Rollback(ctx)
return fmt.Errorf("execute migration %s: %w", name, err)
}
if _, err := tx.Exec(ctx, "INSERT INTO schema_migrations (version) VALUES ($1)", name); err != nil {
tx.Rollback(ctx)
return fmt.Errorf("record migration %s: %w", name, err)
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit migration %s: %w", name, err)
}
}
return nil
}
func (d *DB) Close() {
d.Pool.Close()
}
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"testing"
"time"
"github.com/dgraph-io/badger/v4"
"github.com/luxfi/zapdb/v4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
+2 -2
View File
@@ -3,8 +3,8 @@ package kvstore
import (
"errors"
"github.com/dgraph-io/badger/v4"
"github.com/dgraph-io/badger/v4/options"
"github.com/luxfi/zapdb/v4"
"github.com/luxfi/zapdb/v4/options"
"github.com/luxfi/mpc/pkg/logger"
)
+1 -1
View File
@@ -15,7 +15,7 @@ import (
"sort"
"time"
"github.com/dgraph-io/badger/v4"
"github.com/luxfi/zapdb/v4"
"github.com/rs/zerolog/log"
"github.com/luxfi/mpc/pkg/encryption"
+1 -1
View File
@@ -3,7 +3,7 @@ package kvstore
import (
"fmt"
"github.com/dgraph-io/badger/v4"
"github.com/luxfi/zapdb/v4"
"github.com/luxfi/mpc/pkg/logger"
)
+98
View File
@@ -0,0 +1,98 @@
// Package smart provides Ethereum ABI encoding utilities for Safe and ERC-4337.
// Uses only luxfi/crypto — no go-ethereum or third-party ABI libraries.
package smart
import (
"encoding/binary"
"encoding/hex"
"math/big"
"strings"
)
// abi32 left-pads b to 32 bytes (Ethereum ABI fixed slot).
func abi32(b []byte) []byte {
slot := make([]byte, 32)
if len(b) > 32 {
copy(slot, b[len(b)-32:])
} else {
copy(slot[32-len(b):], b)
}
return slot
}
// abiAddress ABI-encodes a hex address (with or without 0x) as a 32-byte slot.
func abiAddress(addr string) []byte {
addr = strings.TrimPrefix(addr, "0x")
addr = strings.TrimPrefix(addr, "0X")
if len(addr) < 40 {
addr = strings.Repeat("0", 40-len(addr)) + addr
}
b, _ := hex.DecodeString(addr[len(addr)-40:])
return abi32(b)
}
// abiUint256 ABI-encodes a *big.Int as a 32-byte slot.
func abiUint256(n *big.Int) []byte {
if n == nil {
return make([]byte, 32)
}
return abi32(n.Bytes())
}
// abiUint256Int ABI-encodes an int64 as a 32-byte slot.
func abiUint256Int(n int64) []byte {
return abiUint256(big.NewInt(n))
}
// abiBytes32 ABI-encodes a 32-byte value as a 32-byte slot (right-pad with zeros).
func abiBytes32(b []byte) []byte {
slot := make([]byte, 32)
copy(slot, b)
return slot
}
// abiDynBytes ABI-encodes a dynamic bytes value (length + data, right-padded to 32-byte boundary).
func abiDynBytes(data []byte) []byte {
// length slot
lenSlot := abiUint256(big.NewInt(int64(len(data))))
// data padded to 32-byte multiple
padLen := (len(data) + 31) &^ 31
padded := make([]byte, padLen)
copy(padded, data)
return append(lenSlot, padded...)
}
// abiSelector returns the first 4 bytes of keccak256(signature).
func abiSelector(sig string) []byte {
h := keccak256([]byte(sig))
return h[:4]
}
// keccak256 computes keccak256 of data using golang.org/x/crypto/sha3.
func keccak256(data ...[]byte) []byte {
// Import is handled via bridge in bridge.go (same package).
// Implement directly using sha3.NewLegacyKeccak256 from x/crypto.
h := newKeccak256()
for _, d := range data {
h.Write(d)
}
return h.Sum(nil)
}
// abiEncodeAddressArray encodes a dynamic address[] value.
// Returns the head offset and tail data for embedding in a larger tuple.
func abiEncodeAddressArray(addrs []string) []byte {
// length + each address
out := abiUint256(big.NewInt(int64(len(addrs))))
for _, a := range addrs {
out = append(out, abiAddress(a)...)
}
return out
}
// encodeUint32BE encodes a uint32 as big-endian 4 bytes.
func encodeUint32BE(n uint32) []byte {
b := make([]byte, 4)
binary.BigEndian.PutUint32(b, n)
return b
}
+197 -35
View File
@@ -1,54 +1,216 @@
package smart
// ERC4337 provides integration with ERC-4337 Account Abstraction.
// MPC EOA signs UserOperation hashes, which are then submitted to a bundler.
import (
"encoding/hex"
"fmt"
"math/big"
"strings"
)
// UserOperation represents an ERC-4337 user operation.
// ERC-4337 EntryPoint v0.6 address (deployed on all major EVM chains).
const EntrypointV06 = "0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789"
// UserOperation represents an ERC-4337 user operation (v0.6).
type UserOperation struct {
Sender string `json:"sender"`
Nonce string `json:"nonce"`
InitCode string `json:"initCode"`
CallData string `json:"callData"`
CallGasLimit string `json:"callGasLimit"`
VerificationGasLimit string `json:"verificationGasLimit"`
PreVerificationGas string `json:"preVerificationGas"`
MaxFeePerGas string `json:"maxFeePerGas"`
MaxPriorityFeePerGas string `json:"maxPriorityFeePerGas"`
PaymasterAndData string `json:"paymasterAndData"`
Signature string `json:"signature"`
Nonce string `json:"nonce"` // hex uint256
InitCode string `json:"initCode"` // 0x-prefixed bytes (factory+calldata)
CallData string `json:"callData"` // 0x-prefixed bytes
CallGasLimit string `json:"callGasLimit"` // hex uint256
VerificationGasLimit string `json:"verificationGasLimit"` // hex uint256
PreVerificationGas string `json:"preVerificationGas"` // hex uint256
MaxFeePerGas string `json:"maxFeePerGas"` // hex uint256
MaxPriorityFeePerGas string `json:"maxPriorityFeePerGas"` // hex uint256
PaymasterAndData string `json:"paymasterAndData"` // 0x-prefixed bytes
Signature string `json:"signature"` // 0x-prefixed bytes
}
// AccountConfig holds parameters for deploying an ERC-4337 account.
// AccountConfig holds parameters for deploying an ERC-4337 SimpleAccount.
type AccountConfig struct {
FactoryAddress string // SimpleAccountFactory or custom factory
EntrypointAddress string // EntryPoint contract (v0.6 or v0.7)
OwnerAddress string // MPC EOA that controls the account
Salt string // Deterministic deployment salt
FactoryAddress string // SimpleAccountFactory address
EntrypointAddress string // EntryPoint address (defaults to EntrypointV06)
OwnerAddress string // MPC EOA address that will control the account
Salt string // uint256 salt for deterministic deployment
}
// PredictAccountAddress computes the counterfactual address of an ERC-4337 account.
// EncodeInitCode builds the initCode for first-time ERC-4337 account deployment.
// initCode = factory address (20 bytes) + createAccount(owner, salt) calldata.
func EncodeInitCode(cfg AccountConfig) ([]byte, error) {
if cfg.FactoryAddress == "" {
return nil, fmt.Errorf("erc4337: factory address required")
}
if cfg.OwnerAddress == "" {
return nil, fmt.Errorf("erc4337: owner address required")
}
// createAccount(address owner, uint256 salt)
sel := abiSelector("createAccount(address,uint256)")
salt := new(big.Int)
if cfg.Salt != "" {
s := strings.TrimPrefix(cfg.Salt, "0x")
salt.SetString(s, 16)
}
calldata := make([]byte, 0, 4+64)
calldata = append(calldata, sel...)
calldata = append(calldata, abiAddress(cfg.OwnerAddress)...)
calldata = append(calldata, abiUint256(salt)...)
// initCode = factory bytes (20) + calldata
factoryHex := strings.TrimPrefix(cfg.FactoryAddress, "0x")
factoryBytes, err := hex.DecodeString(factoryHex)
if err != nil {
return nil, fmt.Errorf("erc4337: invalid factory address: %w", err)
}
if len(factoryBytes) < 20 {
pad := make([]byte, 20-len(factoryBytes))
factoryBytes = append(pad, factoryBytes...)
}
factoryBytes = factoryBytes[len(factoryBytes)-20:]
initCode := append(factoryBytes, calldata...)
return initCode, nil
}
// PredictAccountAddress computes the counterfactual CREATE2 address of the SimpleAccount.
// Uses getAddress(owner, salt) logic from SimpleAccountFactory.
func PredictAccountAddress(cfg AccountConfig) string {
// CREATE2 prediction based on factory, owner, and salt
if cfg.FactoryAddress == "" || cfg.OwnerAddress == "" {
return ""
}
// SimpleAccount creation code is factory-specific.
// We use the standard keccak256(createAccount selector || abi.encode(owner, salt))
// pattern that ERC-4337 factories use for deterministic deployment.
salt := new(big.Int)
if cfg.Salt != "" {
s := strings.TrimPrefix(cfg.Salt, "0x")
salt.SetString(s, 16)
}
// salt used in CREATE2 = keccak256(abi.encode(owner, salt)) per SimpleAccountFactory
innerSalt := keccak256(
abiAddress(cfg.OwnerAddress),
abiUint256(salt),
)
// We cannot know the exact initCode without the factory's bytecode.
// Return a placeholder that signals "will be determined on-chain".
// Actual address is set after deployment by polling the factory's getAddress().
_ = innerSalt
return ""
}
// hexToBig converts a 0x-prefixed or plain hex string to *big.Int.
func hexToBig(s string) *big.Int {
s = strings.TrimPrefix(s, "0x")
s = strings.TrimPrefix(s, "0X")
n := new(big.Int)
if s != "" {
n.SetString(s, 16)
}
return n
}
// decBytes converts 0x-prefixed hex to []byte. Returns nil on empty/0x.
func decBytes(s string) []byte {
s = strings.TrimPrefix(s, "0x")
s = strings.TrimPrefix(s, "0X")
if s == "" {
return nil
}
b, _ := hex.DecodeString(s)
return b
}
// PackUserOp ABI-encodes the UserOperation fields for hashing (ERC-4337 v0.6 spec).
// Returns the packed bytes that get keccak256'd to form the userOpHash inner hash.
func PackUserOp(op *UserOperation) []byte {
// Per EIP-4337 v0.6: pack = abi.encode(
// sender, nonce, keccak256(initCode), keccak256(callData),
// callGasLimit, verificationGasLimit, preVerificationGas,
// maxFeePerGas, maxPriorityFeePerGas, keccak256(paymasterAndData)
// )
initCodeHash := keccak256(decBytes(op.InitCode))
callDataHash := keccak256(decBytes(op.CallData))
paymasterHash := keccak256(decBytes(op.PaymasterAndData))
packed := make([]byte, 0, 32*10)
packed = append(packed, abiAddress(op.Sender)...)
packed = append(packed, abiUint256(hexToBig(op.Nonce))...)
packed = append(packed, initCodeHash...)
packed = append(packed, callDataHash...)
packed = append(packed, abiUint256(hexToBig(op.CallGasLimit))...)
packed = append(packed, abiUint256(hexToBig(op.VerificationGasLimit))...)
packed = append(packed, abiUint256(hexToBig(op.PreVerificationGas))...)
packed = append(packed, abiUint256(hexToBig(op.MaxFeePerGas))...)
packed = append(packed, abiUint256(hexToBig(op.MaxPriorityFeePerGas))...)
packed = append(packed, paymasterHash...)
return packed
}
// HashUserOp computes the userOpHash that the MPC key must sign.
// This is what gets submitted as the signature field in the UserOperation.
// Returns 32 bytes — pass to MPC TriggerSign as payload.
func HashUserOp(op *UserOperation, entrypoint string, chainID int64) ([]byte, error) {
if op == nil {
return nil, fmt.Errorf("erc4337: nil UserOperation")
}
if entrypoint == "" {
entrypoint = EntrypointV06
}
// userOpHash = keccak256(abi.encode(keccak256(pack(userOp)), entrypoint, chainId))
innerHash := keccak256(PackUserOp(op))
encoded := make([]byte, 0, 96)
encoded = append(encoded, innerHash...)
encoded = append(encoded, abiAddress(entrypoint)...)
encoded = append(encoded, abiUint256Int(chainID)...)
return keccak256(encoded), nil
}
// BuildUserOp creates a UserOperation for a simple ETH transfer or contract call.
// The Nonce, gas fields, and PaymasterAndData must be set by the caller after
// fetching them from the bundler/EntryPoint.
func BuildUserOp(sender, to, value, callData string, nonce uint64) *UserOperation {
// Encode execute(address dest, uint256 value, bytes calldata func) calldata
// Standard for SimpleAccount.
sel := abiSelector("execute(address,uint256,bytes)")
val := new(big.Int)
if value != "" {
val.SetString(value, 10)
}
var funcData []byte
if callData != "" && callData != "0x" {
funcData, _ = hex.DecodeString(strings.TrimPrefix(callData, "0x"))
}
// 3 params: to(addr), value(uint256), data(bytes). Dynamic: data (idx 2). Head = 96.
dataEncoded := abiDynBytes(funcData)
dataOffset := big.NewInt(96)
execCalldata := make([]byte, 0)
execCalldata = append(execCalldata, sel...)
execCalldata = append(execCalldata, abiAddress(to)...)
execCalldata = append(execCalldata, abiUint256(val)...)
execCalldata = append(execCalldata, abiUint256(dataOffset)...)
execCalldata = append(execCalldata, dataEncoded...)
nonceHex := fmt.Sprintf("0x%x", nonce)
return &UserOperation{
Sender: sender,
Nonce: "0x0",
CallData: callData,
Sender: sender,
Nonce: nonceHex,
InitCode: "0x",
CallData: "0x" + hex.EncodeToString(execCalldata),
CallGasLimit: "0x15f90", // 90000 — caller should override with estimate
VerificationGasLimit: "0x186a0", // 100000
PreVerificationGas: "0xbb8", // 3000
MaxFeePerGas: "0x3b9aca00", // 1 gwei
MaxPriorityFeePerGas: "0x3b9aca00",
PaymasterAndData: "0x",
}
}
// HashUserOp computes the hash that needs to be signed for a UserOperation.
func HashUserOp(op *UserOperation, entrypoint string, chainID int64) ([]byte, error) {
// pack(userOp) → keccak256 → keccak256(abi.encode(userOpHash, entrypoint, chainId))
return nil, nil
}
// EncodeInitCode builds the initCode for first-time account deployment.
func EncodeInitCode(cfg AccountConfig) ([]byte, error) {
// factory address + factory.createAccount(owner, salt) calldata
return nil, nil
}
+12
View File
@@ -0,0 +1,12 @@
package smart
import (
"hash"
"golang.org/x/crypto/sha3"
)
// newKeccak256 returns a new legacy Keccak-256 hasher (Ethereum-compatible).
func newKeccak256() hash.Hash {
return sha3.NewLegacyKeccak256()
}
+291 -29
View File
@@ -1,25 +1,38 @@
package smart
// Safe provides integration with Gnosis Safe (now Safe{Wallet}) smart contract wallets.
// Package smart provides Gnosis Safe and ERC-4337 smart contract wallet integration.
// The MPC EOA serves as one of the Safe owners, signing Safe transaction hashes
// rather than raw Ethereum transactions.
package smart
import (
"encoding/hex"
"fmt"
"math/big"
"strings"
)
// Well-known Safe contract addresses (same across all EVM chains for v1.3.0).
const (
SafeSingletonV130 = "0xd9Db270c1B5E3Bd161E8c8503c55cEABeE709552"
SafeProxyFactoryV130 = "0xa6B71E26C5e0845f74c812102Ca7114b6a896AB2"
SafeFallbackHandler = "0xf48f2B2d2a534e402487b3ee7C18c33Aec0Fe5e4"
)
// SafeConfig holds deployment parameters for a new Safe.
type SafeConfig struct {
FactoryAddress string // Safe ProxyFactory address
SingletonAddr string // Safe singleton (master copy) address
Owners []string // Owner addresses (includes the MPC EOA)
Threshold int // Number of required confirmations
Salt string // CREATE2 salt for deterministic deployment
FallbackHandler string // Fallback handler address
FactoryAddress string // Safe ProxyFactory address
SingletonAddr string // Safe singleton (master copy) address
Owners []string // Owner addresses (includes the MPC EOA)
Threshold int // Number of required confirmations
Salt string // CREATE2 salt (hex, 0x-prefixed or plain 32-byte hex)
FallbackHandler string // Fallback handler address
}
// SafeTransaction represents a queued Safe transaction.
// SafeTransaction represents a queued Safe transaction (EIP-712 typed).
type SafeTransaction struct {
To string `json:"to"`
Value string `json:"value"`
Data string `json:"data"`
Operation int `json:"operation"` // 0=Call, 1=DelegateCall
Value string `json:"value"` // decimal wei
Data string `json:"data"` // 0x-prefixed hex calldata
Operation int `json:"operation"` // 0=Call, 1=DelegateCall
SafeTxGas string `json:"safe_tx_gas"`
BaseGas string `json:"base_gas"`
GasPrice string `json:"gas_price"`
@@ -28,28 +41,277 @@ type SafeTransaction struct {
Nonce int `json:"nonce"`
}
// PredictAddress computes the CREATE2 address for a Safe deployment.
func PredictAddress(cfg SafeConfig) string {
// CREATE2: keccak256(0xff ++ factory ++ salt ++ keccak256(initCode))
// Implementation depends on chain RPC - placeholder for now
return ""
// Safe EIP-712 type hashes — computed once at init.
var (
safeDomainTypehash = keccak256([]byte(
"EIP712Domain(uint256 chainId,address verifyingContract)",
))
safeTxTypehash = keccak256([]byte(
"SafeTx(address to,uint256 value,bytes data,uint8 operation," +
"uint256 safeTxGas,uint256 baseGas,uint256 gasPrice," +
"address gasToken,address refundReceiver,uint256 nonce)",
))
)
func applyDefaults(cfg *SafeConfig) {
if cfg.FactoryAddress == "" {
cfg.FactoryAddress = SafeProxyFactoryV130
}
if cfg.SingletonAddr == "" {
cfg.SingletonAddr = SafeSingletonV130
}
if cfg.FallbackHandler == "" {
cfg.FallbackHandler = SafeFallbackHandler
}
}
// EncodeDeploy returns the calldata for Safe ProxyFactory.createProxyWithNonce()
func parseSaltNonce(salt string) *big.Int {
salt = strings.TrimPrefix(salt, "0x")
salt = strings.TrimPrefix(salt, "0X")
if salt == "" {
return big.NewInt(0)
}
n := new(big.Int)
n.SetString(salt, 16)
return n
}
// encodeSetup builds Safe.setup() calldata (the initializer for the proxy).
func encodeSetup(cfg SafeConfig) []byte {
sel := abiSelector("setup(address[],uint256,address,bytes,address,address,uint256,address)")
// 8 params: owners(addr[]), threshold(uint256), to(addr), data(bytes),
// fallbackHandler(addr), paymentToken(addr), payment(uint256), paymentReceiver(addr)
// Dynamic: owners (idx 0), data (idx 3).
// Head = 8 × 32 = 256 bytes.
// owners offset = 256
ownersData := abiEncodeAddressArray(cfg.Owners)
ownersOffset := big.NewInt(256)
// empty bytes for `data` starts after owners
emptyBytesOffset := new(big.Int).Add(ownersOffset, big.NewInt(int64(len(ownersData))))
heads := make([]byte, 0, 256)
heads = append(heads, abiUint256(ownersOffset)...)
heads = append(heads, abiUint256Int(int64(cfg.Threshold))...)
heads = append(heads, abiAddress("0x0000000000000000000000000000000000000000")...)
heads = append(heads, abiUint256(emptyBytesOffset)...)
heads = append(heads, abiAddress(cfg.FallbackHandler)...)
heads = append(heads, abiAddress("0x0000000000000000000000000000000000000000")...)
heads = append(heads, abiUint256Int(0)...)
heads = append(heads, abiAddress("0x0000000000000000000000000000000000000000")...)
calldata := make([]byte, 0)
calldata = append(calldata, sel...)
calldata = append(calldata, heads...)
calldata = append(calldata, ownersData...)
calldata = append(calldata, abiDynBytes(nil)...) // empty bytes for `data`
return calldata
}
// EncodeDeploy returns the calldata for Safe ProxyFactory.createProxyWithNonce().
func EncodeDeploy(cfg SafeConfig) ([]byte, error) {
// ABI encode: createProxyWithNonce(singleton, initializer, saltNonce)
// initializer = setup(owners, threshold, to, data, fallbackHandler, paymentToken, payment, paymentReceiver)
return nil, nil
applyDefaults(&cfg)
if len(cfg.Owners) == 0 {
return nil, fmt.Errorf("safe: at least one owner required")
}
if cfg.Threshold < 1 || cfg.Threshold > len(cfg.Owners) {
return nil, fmt.Errorf("safe: threshold must be 1..%d", len(cfg.Owners))
}
sel := abiSelector("createProxyWithNonce(address,bytes,uint256)")
initializer := encodeSetup(cfg)
// 3 params: singleton(addr), initializer(bytes), saltNonce(uint256)
// Dynamic: initializer (idx 1). Head = 3×32 = 96 bytes. initializer offset = 96.
initOffset := big.NewInt(96)
initData := abiDynBytes(initializer)
calldata := make([]byte, 0)
calldata = append(calldata, sel...)
calldata = append(calldata, abiAddress(cfg.SingletonAddr)...)
calldata = append(calldata, abiUint256(initOffset)...)
calldata = append(calldata, abiUint256(parseSaltNonce(cfg.Salt))...)
calldata = append(calldata, initData...)
return calldata, nil
}
// EncodeExecTransaction returns calldata for Safe.execTransaction()
func EncodeExecTransaction(tx SafeTransaction, signatures []byte) ([]byte, error) {
// ABI encode the execTransaction call with packed signatures
return nil, nil
// PredictAddress computes the CREATE2 address for a Safe deployment.
// Returns a 0x-prefixed lowercase hex address.
func PredictAddress(cfg SafeConfig) string {
applyDefaults(&cfg)
initializer := encodeSetup(cfg)
// Safe ProxyFactory CREATE2 salt: keccak256(keccak256(initializer) ++ saltNonce[32])
initHash := keccak256(initializer)
sn := parseSaltNonce(cfg.Salt)
snSlot := abiUint256(sn)
factorySalt := keccak256(initHash, snSlot)
// Safe proxy creation code for v1.3.0 + ABI-encoded singleton appended.
proxyCreationCode, _ := hex.DecodeString(
"608060405234801561001057600080fd5b506040516101e63803806101e6833981810160405260" +
"208110156100335760008060fd5b810190808051906020019092919050505060" +
"0073ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffff" +
"ffffffffffffffffffffffffff1614156100ca57604051600080fd5b806000806" +
"101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373" +
"ffffffffffffffffffffffffffffffffffffffff1602179055505060ab806101" +
"196000396000f3fe608060405273ffffffffffffffffffffffffffffff" +
"ffffffffff600054167fa619486e0000000000000000000000000000000000000" +
"00000000000000060003514156050578060005260206000f35b3660008037600" +
"080366000845af43d6000803e60008015156068573d6000fd5b3d6000f3fea264" +
"6970667358221220d1429297349653a4918076d650332de1a1068c5f3e07c5c82" +
"360c277770b95264736f6c63430007060033",
)
initCode := append(proxyCreationCode, abiAddress(cfg.SingletonAddr)...)
initCodeHash := keccak256(initCode)
// Decode factory address
factoryHex := strings.TrimPrefix(cfg.FactoryAddress, "0x")
factoryBytes, _ := hex.DecodeString(factoryHex)
if len(factoryBytes) < 20 {
pad := make([]byte, 20-len(factoryBytes))
factoryBytes = append(pad, factoryBytes...)
}
factoryBytes = factoryBytes[len(factoryBytes)-20:]
preimage := make([]byte, 0, 85)
preimage = append(preimage, 0xff)
preimage = append(preimage, factoryBytes...)
preimage = append(preimage, factorySalt...)
preimage = append(preimage, initCodeHash...)
addrBytes := keccak256(preimage)
return "0x" + hex.EncodeToString(addrBytes[12:])
}
// HashSafeTransaction computes the EIP-712 hash that signers must sign.
// HashSafeTransaction computes the EIP-712 hash that all owners must sign.
// Returns the 32-byte hash — pass this to MPC TriggerSign as the payload.
func HashSafeTransaction(safeAddress string, chainID int64, tx SafeTransaction) ([]byte, error) {
// EIP-712 typed data hash for Safe transaction
return nil, nil
// Domain separator
domainSep := keccak256(
safeDomainTypehash,
abiUint256Int(chainID),
abiAddress(safeAddress),
)
// Parse tx fields
value := new(big.Int)
if tx.Value != "" {
value.SetString(tx.Value, 10)
}
var dataBytes []byte
if tx.Data != "" && tx.Data != "0x" {
raw := strings.TrimPrefix(tx.Data, "0x")
var err error
dataBytes, err = hex.DecodeString(raw)
if err != nil {
return nil, fmt.Errorf("safe: invalid data hex: %w", err)
}
}
dataHash := keccak256(dataBytes)
safeTxGas := parseBigInt(tx.SafeTxGas)
baseGas := parseBigInt(tx.BaseGas)
gasPrice := parseBigInt(tx.GasPrice)
gasToken := tx.GasToken
if gasToken == "" {
gasToken = "0x0000000000000000000000000000000000000000"
}
refundReceiver := tx.RefundReceiver
if refundReceiver == "" {
refundReceiver = "0x0000000000000000000000000000000000000000"
}
// safeTxHash = keccak256(SAFE_TX_TYPEHASH || to || value || keccak256(data) ||
// operation || safeTxGas || baseGas || gasPrice ||
// gasToken || refundReceiver || nonce)
encoded := make([]byte, 0, 32*11)
encoded = append(encoded, safeTxTypehash...)
encoded = append(encoded, abiAddress(tx.To)...)
encoded = append(encoded, abiUint256(value)...)
encoded = append(encoded, dataHash...)
encoded = append(encoded, abiUint256Int(int64(tx.Operation))...)
encoded = append(encoded, abiUint256(safeTxGas)...)
encoded = append(encoded, abiUint256(baseGas)...)
encoded = append(encoded, abiUint256(gasPrice)...)
encoded = append(encoded, abiAddress(gasToken)...)
encoded = append(encoded, abiAddress(refundReceiver)...)
encoded = append(encoded, abiUint256Int(int64(tx.Nonce))...)
safeTxHash := keccak256(encoded)
// Final EIP-712: keccak256(\x19\x01 || domainSep || safeTxHash)
msg := append([]byte{0x19, 0x01}, domainSep...)
msg = append(msg, safeTxHash...)
return keccak256(msg), nil
}
// EncodeExecTransaction returns calldata for Safe.execTransaction() with packed sigs.
func EncodeExecTransaction(tx SafeTransaction, signatures []byte) ([]byte, error) {
sel := abiSelector("execTransaction(address,uint256,bytes,uint8,uint256,uint256,uint256,address,address,bytes)")
value := parseBigInt(tx.Value)
var dataBytes []byte
if tx.Data != "" && tx.Data != "0x" {
raw := strings.TrimPrefix(tx.Data, "0x")
var err error
dataBytes, err = hex.DecodeString(raw)
if err != nil {
return nil, fmt.Errorf("safe: invalid data hex: %w", err)
}
}
safeTxGas := parseBigInt(tx.SafeTxGas)
baseGas := parseBigInt(tx.BaseGas)
gasPrice := parseBigInt(tx.GasPrice)
gasToken := tx.GasToken
if gasToken == "" {
gasToken = "0x0000000000000000000000000000000000000000"
}
refundReceiver := tx.RefundReceiver
if refundReceiver == "" {
refundReceiver = "0x0000000000000000000000000000000000000000"
}
// 10 params; dynamic: data (idx 2), signatures (idx 9). Head = 10×32 = 320 bytes.
dataEncoded := abiDynBytes(dataBytes)
dataOffset := big.NewInt(320)
sigsOffset := new(big.Int).Add(dataOffset, big.NewInt(int64(len(dataEncoded))))
sigsEncoded := abiDynBytes(signatures)
heads := make([]byte, 0, 320)
heads = append(heads, abiAddress(tx.To)...)
heads = append(heads, abiUint256(value)...)
heads = append(heads, abiUint256(dataOffset)...)
heads = append(heads, abiUint256Int(int64(tx.Operation))...)
heads = append(heads, abiUint256(safeTxGas)...)
heads = append(heads, abiUint256(baseGas)...)
heads = append(heads, abiUint256(gasPrice)...)
heads = append(heads, abiAddress(gasToken)...)
heads = append(heads, abiAddress(refundReceiver)...)
heads = append(heads, abiUint256(sigsOffset)...)
calldata := make([]byte, 0)
calldata = append(calldata, sel...)
calldata = append(calldata, heads...)
calldata = append(calldata, dataEncoded...)
calldata = append(calldata, sigsEncoded...)
return calldata, nil
}
// PackSignature packs MPC r+s+v into the 65-byte format Safe expects per owner.
func PackSignature(r, s []byte, v byte) []byte {
sig := make([]byte, 65)
copy(sig[32-len(r):32], r)
copy(sig[64-len(s):64], s)
sig[64] = v
return sig
}
func parseBigInt(s string) *big.Int {
n := new(big.Int)
if s != "" {
n.SetString(s, 10)
}
return n
}
+155
View File
@@ -0,0 +1,155 @@
package smart
import (
"encoding/hex"
"strings"
"testing"
)
// TestSafeEncodeDeploy verifies EncodeDeploy produces valid 4-byte selector + ABI data.
func TestSafeEncodeDeploy(t *testing.T) {
cfg := SafeConfig{
Owners: []string{"0xEAbCC110fAcBfebabC66Ad6f9E7B67288e720B59"},
Threshold: 1,
Salt: "0x1",
}
calldata, err := EncodeDeploy(cfg)
if err != nil {
t.Fatalf("EncodeDeploy: %v", err)
}
if len(calldata) < 4 {
t.Fatalf("calldata too short: %d bytes", len(calldata))
}
// Selector for createProxyWithNonce(address,bytes,uint256)
wantSel := abiSelector("createProxyWithNonce(address,bytes,uint256)")
gotSel := calldata[:4]
if hex.EncodeToString(gotSel) != hex.EncodeToString(wantSel) {
t.Errorf("wrong selector: got %x want %x", gotSel, wantSel)
}
}
// TestSafeHashSafeTransaction verifies EIP-712 hash is 32 bytes and deterministic.
func TestSafeHashSafeTransaction(t *testing.T) {
tx := SafeTransaction{
To: "0xEAbCC110fAcBfebabC66Ad6f9E7B67288e720B59",
Value: "1000000000000000000",
Nonce: 0,
}
hash1, err := HashSafeTransaction("0xd9Db270c1B5E3Bd161E8c8503c55cEABeE709552", 1, tx)
if err != nil {
t.Fatalf("HashSafeTransaction: %v", err)
}
if len(hash1) != 32 {
t.Errorf("expected 32-byte hash, got %d bytes", len(hash1))
}
// Deterministic
hash2, _ := HashSafeTransaction("0xd9Db270c1B5E3Bd161E8c8503c55cEABeE709552", 1, tx)
if hex.EncodeToString(hash1) != hex.EncodeToString(hash2) {
t.Error("hash not deterministic")
}
// Different chain ID → different hash
hash3, _ := HashSafeTransaction("0xd9Db270c1B5E3Bd161E8c8503c55cEABeE709552", 137, tx)
if hex.EncodeToString(hash1) == hex.EncodeToString(hash3) {
t.Error("expected different hash for different chainID")
}
}
// TestSafePredictAddress verifies PredictAddress returns a valid hex address.
func TestSafePredictAddress(t *testing.T) {
cfg := SafeConfig{
Owners: []string{"0xEAbCC110fAcBfebabC66Ad6f9E7B67288e720B59"},
Threshold: 1,
Salt: "0x0",
}
addr := PredictAddress(cfg)
if !strings.HasPrefix(addr, "0x") {
t.Errorf("expected 0x-prefixed address, got %q", addr)
}
if len(addr) != 42 {
t.Errorf("expected 42-char address, got %d chars: %s", len(addr), addr)
}
}
// TestEncodeExecTransaction verifies execTransaction calldata has correct selector.
func TestEncodeExecTransaction(t *testing.T) {
tx := SafeTransaction{
To: "0xEAbCC110fAcBfebabC66Ad6f9E7B67288e720B59",
Value: "0",
}
sigs := PackSignature(make([]byte, 32), make([]byte, 32), 27)
calldata, err := EncodeExecTransaction(tx, sigs)
if err != nil {
t.Fatalf("EncodeExecTransaction: %v", err)
}
wantSel := abiSelector("execTransaction(address,uint256,bytes,uint8,uint256,uint256,uint256,address,address,bytes)")
if hex.EncodeToString(calldata[:4]) != hex.EncodeToString(wantSel) {
t.Errorf("wrong selector: got %x want %x", calldata[:4], wantSel)
}
}
// TestBuildUserOp verifies BuildUserOp returns a valid UserOperation.
func TestBuildUserOp(t *testing.T) {
op := BuildUserOp(
"0xEAbCC110fAcBfebabC66Ad6f9E7B67288e720B59",
"0xd9Db270c1B5E3Bd161E8c8503c55cEABeE709552",
"1000000000000000000",
"",
0,
)
if op.Sender == "" {
t.Error("expected sender")
}
if !strings.HasPrefix(op.CallData, "0x") {
t.Error("expected 0x-prefixed calldata")
}
}
// TestHashUserOp verifies ERC-4337 userOpHash is 32 bytes and deterministic.
func TestHashUserOp(t *testing.T) {
op := BuildUserOp(
"0xEAbCC110fAcBfebabC66Ad6f9E7B67288e720B59",
"0xd9Db270c1B5E3Bd161E8c8503c55cEABeE709552",
"0",
"",
42,
)
hash1, err := HashUserOp(op, EntrypointV06, 1)
if err != nil {
t.Fatalf("HashUserOp: %v", err)
}
if len(hash1) != 32 {
t.Errorf("expected 32-byte hash, got %d", len(hash1))
}
hash2, _ := HashUserOp(op, EntrypointV06, 1)
if hex.EncodeToString(hash1) != hex.EncodeToString(hash2) {
t.Error("hash not deterministic")
}
}
// TestEncodeInitCode verifies the ERC-4337 initCode format.
func TestEncodeInitCode(t *testing.T) {
cfg := AccountConfig{
FactoryAddress: "0xa6B71E26C5e0845f74c812102Ca7114b6a896AB2",
OwnerAddress: "0xEAbCC110fAcBfebabC66Ad6f9E7B67288e720B59",
Salt: "0x0",
}
initCode, err := EncodeInitCode(cfg)
if err != nil {
t.Fatalf("EncodeInitCode: %v", err)
}
// First 20 bytes = factory address
if len(initCode) < 20 {
t.Fatalf("initCode too short: %d bytes", len(initCode))
}
gotFactory := "0x" + hex.EncodeToString(initCode[:20])
wantFactory := strings.ToLower(cfg.FactoryAddress)
if strings.ToLower(gotFactory) != wantFactory {
t.Errorf("factory mismatch: got %s want %s", gotFactory, wantFactory)
}
// Bytes 20+ = createAccount selector + params
remaining := initCode[20:]
wantSel := abiSelector("createAccount(address,uint256)")
if len(remaining) < 4 || hex.EncodeToString(remaining[:4]) != hex.EncodeToString(wantSel) {
t.Errorf("wrong createAccount selector: got %x want %x", remaining[:4], wantSel)
}
}
+19 -19
View File
@@ -7,7 +7,7 @@
// - ZAP wire protocol for messaging (replaces NATS)
// - Consensus Membership with Ed25519 keys as PoA validators (replaces Consul)
// - StateStore for replicated key-value state (replaces PostgreSQL/Redis)
// - Local BadgerDB for encrypted key share storage (unchanged)
// - ZapKVStore (Valkey via hanzoai/kv-go) for key share storage (replaces BadgerDB)
//
// Architecture:
//
@@ -86,13 +86,13 @@ type FactoryConfig struct {
// PublicKey is this node's Ed25519 public key
PublicKey ed25519.PublicKey
// BadgerPath is the path for local BadgerDB storage
BadgerPath string
// ZapDBPath is the path for local ZapDB storage (embedded, replaces BadgerDB)
ZapDBPath string
// BadgerPassword for encryption
BadgerPassword string
// ZapDBPassword for at-rest AES-256 encryption
ZapDBPassword string
// BackupDir for BadgerDB backups
// BackupDir for ZapDB incremental S3 backups
BackupDir string
}
@@ -124,8 +124,8 @@ func NewFactory(config FactoryConfig) (*Factory, error) {
if config.PrivateKey == nil || config.PublicKey == nil {
return nil, fmt.Errorf("Ed25519 keypair is required")
}
if config.BadgerPath == "" {
return nil, fmt.Errorf("BadgerPath is required")
if config.ZapDBPath == "" {
return nil, fmt.Errorf("ZapDBPath is required")
}
// Create transport config
@@ -146,27 +146,27 @@ func NewFactory(config FactoryConfig) (*Factory, error) {
return nil, fmt.Errorf("failed to create transport: %w", err)
}
// Create BadgerDB for local storage
encKey := deriveEncryptionKey(config.BadgerPassword)
// Create ZapDB (embedded ZAP-native KV store) for local key-share storage
encKey := deriveEncryptionKey(config.ZapDBPassword)
if encKey == nil {
encKey = make([]byte, 32) // Default key if none provided
}
badgerConfig := kvstore.BadgerConfig{
zapDBConfig := kvstore.BadgerConfig{
NodeID: config.NodeID,
DBPath: config.BadgerPath,
DBPath: config.ZapDBPath,
BackupDir: config.BackupDir,
EncryptionKey: encKey,
BackupEncryptionKey: encKey, // Use same key for backup
BackupEncryptionKey: encKey,
}
// Ensure directory exists
if err := os.MkdirAll(config.BadgerPath, 0700); err != nil {
return nil, fmt.Errorf("failed to create badger path: %w", err)
if err := os.MkdirAll(config.ZapDBPath, 0700); err != nil {
return nil, fmt.Errorf("failed to create zapdb path: %w", err)
}
badger, err := kvstore.NewBadgerKVStore(badgerConfig)
badger, err := kvstore.NewBadgerKVStore(zapDBConfig)
if err != nil {
return nil, fmt.Errorf("failed to create badger: %w", err)
return nil, fmt.Errorf("failed to create zapdb: %w", err)
}
// Create state store with replication
@@ -253,7 +253,7 @@ func (f *Factory) Stop() error {
logger.Warn("Failed to stop transport", "err", err)
}
// Close state store (which closes BadgerDB)
// Close state store (which closes ZapDB)
if err := f.state.Close(); err != nil {
logger.Warn("Failed to close state store", "err", err)
}
@@ -282,7 +282,7 @@ func (f *Factory) Membership() *Membership {
return f.membership
}
// KVStore returns the local BadgerDB store
// KVStore returns the local ZapDB store (embedded ZAP-native key-value store)
func (f *Factory) KVStore() kvstore.KVStore {
return f.badger
}
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"fmt"
"strings"
"github.com/dgraph-io/badger/v4"
"github.com/luxfi/zapdb/v4"
"github.com/luxfi/mpc/pkg/kvstore"
"github.com/luxfi/mpc/pkg/logger"