mirror of
https://github.com/luxfi/mpc.git
synced 2026-09-24 00:26:24 +00:00
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:
@@ -4,3 +4,5 @@ dashboard/node_modules
|
||||
dashboard/.next
|
||||
node_modules
|
||||
.env
|
||||
|
||||
# Local prebuilt binaries
|
||||
|
||||
+21
-18
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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
@@ -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
@@ -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}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -9,7 +9,6 @@ spec:
|
||||
secretName: mpc-lux-network-tls
|
||||
dnsNames:
|
||||
- mpc.lux.network
|
||||
- cloud.lux.network
|
||||
issuerRef:
|
||||
name: letsencrypt-prod
|
||||
kind: ClusterIssuer
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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: ""
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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, ðAddress)
|
||||
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, ðAddress)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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(ðAddress)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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(ð, &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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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") }
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user