mirror of
https://github.com/luxfi/wallet.git
synced 2026-10-30 15:13:19 +00:00
Add apps/backend — the wallet's custody server. The load-bearing Front-E gap
(repo was FE-only) is now closed: a wallet account model backed by MPC custody
where the private key is split t-of-n across the MPC nodes (lux →
mpc.lux.network) and NEVER assembled — this service stores only public keys +
addresses.
- internal/iam: lux.id OIDC bearer verification via JWKS (discovery + RS256/
ES256, alg-confusion-safe — HMAC/none refused). Org from the 'owner' claim.
golang-jwt/v5 (matches the lux ecosystem), stdlib JWKS fetcher (no extra dep).
- internal/custody: the Custodian port (a value the wallet owns) + HTTP adapter
to lux/mpc's verified /keygen + /sign wire. Mirrors mpc client.Threshold's
security contract (idempotency required, org-scoped). PQ-ready: mldsa65 is a
first-class scheme (the PQ primitives stay in pkgs/wallet/.../pq + consensus —
not duplicated). No in-process key fallback — unconfigured MPC is an error.
- internal/store: wallet↔org↔addresses (no key field). Org-scoped reads — a
cross-org get is ErrNotFound (no existence disclosure).
- internal/api: /v1/* (no /api/ prefix). IAM-gated, org-scoped. POST/GET
/v1/wallets, GET /v1/wallets/{id}, POST /v1/wallets/{id}/sign, GET /v1/health.
- cmd/wallet-backend: env config (white-label per tenant: IAM_ISSUER/AUDIENCE,
MPC_ENDPOINT — shared hanzo-mpc or BYOMPC), JSON logs, graceful shutdown.
Deploy (native CI + operator, NO GHA): build-only .platform.yml → ghcr.io/
luxfi/wallet-backend on lux-build pools; lux operator rolls
apps/backend/k8s/wallet-backend.yaml (lux.cloud/v1 Service + KMSSecret for
MPC_SERVICE_TOKEN), ingress wallet-api.lux.network.
17 tests green (go test ./...): IAM (valid/no-org/wrong-aud/expired/alg-
confusion), custody (no-key-leak, idempotency-required, PQ-accepted, no-
fallback), API (auth-gate, org-isolation cross-org→404, create→sign), + a full
E2E through the real adapter→mock-MPC→real-server. Binary boots, /v1/health ok,
/v1/wallets 401 unauth. LLM.md captures the App(Wallet) spec for the unified
operator's App Kind.
72 lines
2.1 KiB
Go
72 lines
2.1 KiB
Go
// Package store persists the wallet↔org mapping and the wallet's public
|
|
// addresses. It NEVER stores a private key (custody is MPC). Org scoping is
|
|
// enforced here: a wallet read/list is always filtered by org, so one tenant
|
|
// can never see another's wallets.
|
|
package store
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"sync"
|
|
|
|
"github.com/luxfi/wallet/backend/internal/custody"
|
|
)
|
|
|
|
var ErrNotFound = errors.New("store: wallet not found")
|
|
|
|
// Store is the wallet record store. All methods are org-scoped.
|
|
type Store interface {
|
|
// Put records a wallet under an org. Idempotent on WalletID within the org.
|
|
Put(ctx context.Context, org string, w *custody.Wallet) error
|
|
// Get returns the org's wallet by id, or ErrNotFound (also for a wallet
|
|
// owned by a different org — no cross-org existence disclosure).
|
|
Get(ctx context.Context, org, walletID string) (*custody.Wallet, error)
|
|
// List returns all wallets for an org.
|
|
List(ctx context.Context, org string) ([]*custody.Wallet, error)
|
|
}
|
|
|
|
// Memory is an in-process Store. Production swaps in a Postgres-backed Store
|
|
// behind the same interface; the API is indifferent. Records hold public data
|
|
// only — there is no key field to leak.
|
|
type Memory struct {
|
|
mu sync.RWMutex
|
|
// org → walletID → wallet
|
|
byOrg map[string]map[string]*custody.Wallet
|
|
}
|
|
|
|
func NewMemory() *Memory {
|
|
return &Memory{byOrg: map[string]map[string]*custody.Wallet{}}
|
|
}
|
|
|
|
func (m *Memory) Put(_ context.Context, org string, w *custody.Wallet) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
wallets := m.byOrg[org]
|
|
if wallets == nil {
|
|
wallets = map[string]*custody.Wallet{}
|
|
m.byOrg[org] = wallets
|
|
}
|
|
wallets[w.WalletID] = w
|
|
return nil
|
|
}
|
|
|
|
func (m *Memory) Get(_ context.Context, org, walletID string) (*custody.Wallet, error) {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
if w, ok := m.byOrg[org][walletID]; ok {
|
|
return w, nil
|
|
}
|
|
return nil, ErrNotFound
|
|
}
|
|
|
|
func (m *Memory) List(_ context.Context, org string) ([]*custody.Wallet, error) {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
wallets := m.byOrg[org]
|
|
out := make([]*custody.Wallet, 0, len(wallets))
|
|
for _, w := range wallets {
|
|
out = append(out, w)
|
|
}
|
|
return out, nil
|
|
}
|