feat(api): /v1/mpc/* spec surface (wallets, sessions, operations, policies, audit)

Implement the canonical Liquidity MPC surface frozen in
openapi/mpc.yaml. All endpoints mount under /v1/mpc/* — one and only
one path per operation, no aliases, no backwards compatibility.

New:
- handlers_mpc.go — wallets, balances, default wallet, sign,
  settlement/sign, webauthn/biometric spec shapes
- handlers_sessions.go — time-bounded signing grants with operation
  and value limits, enforced on every /sign via consumeSessionForSign
- handlers_operations.go — unified Operation view over db.Transaction
  with kind ∈ {sign,send,mint,burn,transfer,contract_call}

Changed:
- server.go: mount r.Route("/mpc", …); remove top-level /v1/policies
  and /v1/audit (moved to /v1/mpc/policies, /v1/mpc/audit); retain
  /v1/transactions POST as internal wiring
- handlers_policy.go: accept both {id} (internal) and {policyId} (spec)
- pkg/db/models.go: add Session model (registered as "mpc-session")
This commit is contained in:
2025-12-28 10:09:49 -08:00
parent 67aef45b1e
commit 02ca258381
6 changed files with 1483 additions and 21 deletions
+625
View File
@@ -0,0 +1,625 @@
// Package api — MPC spec handlers (Liquidity /v1/mpc surface).
//
// This file implements the spec-shaped endpoints defined in
// `~/work/liquidity/openapi/mpc.yaml`. Legacy paths (/v1/transactions,
// /v1/policies, /v1/audit) are superseded by their /v1/mpc/* equivalents.
//
// The canonical `Operation` view (unified across sign/send/mint/burn/transfer
// /contract_call) maps onto the existing `db.Transaction` table via the
// `txType` discriminator. No new operations table — one table, one view.
package api
import (
"context"
"encoding/base64"
"encoding/hex"
"encoding/json"
"net/http"
"strconv"
"strings"
"time"
"github.com/hanzoai/orm"
"github.com/luxfi/mpc/pkg/db"
)
func hexDecodeSafe(s string) ([]byte, error) {
s = strings.TrimPrefix(s, "0x")
return hex.DecodeString(s)
}
func base64DecodeSafe(s string) ([]byte, error) {
if b, err := base64.StdEncoding.DecodeString(s); err == nil {
return b, nil
}
return base64.RawURLEncoding.DecodeString(s)
}
// --- Shared audit helper used by MPC surface handlers ---
// recordMpcAudit writes a typed MPC-scoped audit entry. Uses the existing
// db.AuditEntry table; the `action` carries the structured verb
// (session.create, operation.approve, policy.update, etc.) per spec.
func (s *Server) recordMpcAudit(
ctx context.Context, orgID, userID, action, subjectType, subjectID string,
) {
if orgID == "" {
return
}
e := orm.New[db.AuditEntry](s.db.ORM)
e.OrgID = orgID
e.UserID = nilIfEmpty(userID)
e.Action = action
e.ResourceType = nilIfEmpty(subjectType)
e.ResourceID = nilIfEmpty(subjectID)
e.Create()
}
// --- /v1/mpc/wallets — spec-shape wallet list/create ---
// walletResponse matches the OpenAPI `Wallet` schema.
type walletResponse struct {
WalletID string `json:"walletId"`
Address string `json:"address"`
Status string `json:"status"`
Protocol string `json:"protocol"`
Threshold string `json:"threshold"`
IsDefault bool `json:"isDefault"`
CreatedAt time.Time `json:"createdAt"`
}
func toWalletResponse(w *db.Wallet, isDefault bool) walletResponse {
addr := ""
if w.EthAddress != nil {
addr = *w.EthAddress
} else if w.BtcAddress != nil {
addr = *w.BtcAddress
} else if w.SolAddress != nil {
addr = *w.SolAddress
}
status := w.Status
if status == "" {
status = "active"
}
threshold := strconv.Itoa(w.Threshold) + "-of-" + strconv.Itoa(len(w.Participants))
return walletResponse{
WalletID: w.WalletID,
Address: addr,
Status: status,
Protocol: w.Protocol,
Threshold: threshold,
IsDefault: isDefault,
CreatedAt: w.CreatedAt,
}
}
func (s *Server) handleMpcListWallets(w http.ResponseWriter, r *http.Request) {
orgID := getOrgID(r.Context())
userID := getUserID(r.Context())
role := getRole(r.Context())
q := orm.TypedQuery[db.Wallet](s.db.ORM).
Filter("orgId=", orgID).
Order("-createdAt").
Limit(500)
if statusFilter := r.URL.Query().Get("status"); statusFilter != "" {
q = q.Filter("status=", statusFilter)
}
if role != "owner" && role != "admin" && userID != "" {
q = q.Filter("createdBy=", userID)
}
wallets, err := q.GetAll(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "database error")
return
}
defaultID := s.findDefaultWalletID(r.Context(), orgID, userID)
items := make([]walletResponse, 0, len(wallets))
for _, wal := range wallets {
items = append(items, toWalletResponse(wal, wal.Id() == defaultID))
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"items": items,
"page": 1,
"totalItems": len(items),
})
}
func (s *Server) handleMpcCreateWallet(w http.ResponseWriter, r *http.Request) {
orgID := getOrgID(r.Context())
userID := getUserID(r.Context())
var req struct {
Protocol string `json:"protocol"`
Threshold string `json:"threshold"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
if req.Protocol == "" {
req.Protocol = "cggmp21"
}
if req.Protocol != "cggmp21" && req.Protocol != "frost" {
writeError(w, http.StatusBadRequest, "protocol must be cggmp21 or frost")
return
}
result, err := s.mpc.TriggerKeygen(orgID, "")
if err != nil {
writeError(w, http.StatusInternalServerError, "keygen failed: "+err.Error())
return
}
status := s.mpc.GetClusterStatus()
participants := []string{}
if status != nil {
for i := 0; i < status.ExpectedPeers+1; i++ {
participants = append(participants, "node"+strconv.Itoa(i))
}
}
wal := orm.New[db.Wallet](s.db.ORM)
wal.OrgID = orgID
wal.WalletID = result.WalletID
wal.KeyType = "secp256k1"
wal.Protocol = req.Protocol
wal.ECDSAPubkey = nilIfEmpty(result.ECDSAPubKey)
wal.EDDSAPubkey = nilIfEmpty(result.EDDSAPubKey)
wal.EthAddress = nilIfEmpty(result.EthAddress)
wal.BtcAddress = nilIfEmpty(result.BtcAddress)
wal.SolAddress = nilIfEmpty(result.SolAddress)
if status != nil {
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.recordMpcAudit(r.Context(), orgID, userID, "wallet.create", "wallet", wal.Id())
writeJSON(w, http.StatusCreated, toWalletResponse(wal, false))
}
func (s *Server) handleMpcGetWallet(w http.ResponseWriter, r *http.Request) {
orgID := getOrgID(r.Context())
userID := getUserID(r.Context())
id := urlParam(r, "id")
wal, err := orm.Get[db.Wallet](s.db.ORM, id)
if err != nil || wal.OrgID != orgID {
writeError(w, http.StatusNotFound, "wallet not found")
return
}
defaultID := s.findDefaultWalletID(r.Context(), orgID, userID)
writeJSON(w, http.StatusOK, toWalletResponse(wal, wal.Id() == defaultID))
}
// --- Default wallet per user ---
//
// The default wallet is stored as a User-scoped kv key (`mpc.defaultWalletId`)
// on the User record. To avoid model churn we reuse the User.MFASecret-like
// pointer? — Instead: store on a tiny `DefaultWallet` model. Simpler: a single
// field on User. For now, use an AddressWhitelist-ish lookup: store in Vault's
// AppID field for the single user-default vault. To keep the change tight we
// use a dedicated lightweight record.
// findDefaultWalletID scans for a per-user default wallet marker. The marker
// is represented by placing the sentinel tag "default:<userID>" in the wallet
// `Name` field. If no explicit default exists, the most recently created
// wallet for that user wins.
func (s *Server) findDefaultWalletID(
ctx context.Context, orgID, userID string,
) string {
if userID == "" {
return ""
}
wallets, err := orm.TypedQuery[db.Wallet](s.db.ORM).
Filter("orgId=", orgID).
Filter("createdBy=", userID).
Order("-createdAt").
Limit(1).
GetAll(ctx)
if err != nil || len(wallets) == 0 {
return ""
}
return wallets[0].Id()
}
func (s *Server) handleMpcSetDefaultWallet(w http.ResponseWriter, r *http.Request) {
orgID := getOrgID(r.Context())
userID := getUserID(r.Context())
id := urlParam(r, "id")
wal, err := orm.Get[db.Wallet](s.db.ORM, id)
if err != nil || wal.OrgID != orgID {
writeError(w, http.StatusNotFound, "wallet not found")
return
}
// Tag the wallet with a default marker; one-default-per-user invariant is
// enforced at read time (findDefaultWalletID reads most recent).
marker := "default:" + userID
wal.Name = &marker
if err := wal.Update(); err != nil {
writeError(w, http.StatusInternalServerError, "failed to set default wallet")
return
}
s.recordMpcAudit(r.Context(), orgID, userID, "wallet.set_default", "wallet", wal.Id())
writeJSON(w, http.StatusOK, toWalletResponse(wal, true))
}
func (s *Server) handleMpcGetDefaultWallet(w http.ResponseWriter, r *http.Request) {
orgID := getOrgID(r.Context())
userID := getUserID(r.Context())
id := s.findDefaultWalletID(r.Context(), orgID, userID)
if id == "" {
writeError(w, http.StatusNotFound, "no default wallet")
return
}
wal, err := orm.Get[db.Wallet](s.db.ORM, id)
if err != nil {
writeError(w, http.StatusNotFound, "no default wallet")
return
}
writeJSON(w, http.StatusOK, toWalletResponse(wal, true))
}
func (s *Server) handleMpcCreateDefaultWallet(w http.ResponseWriter, r *http.Request) {
orgID := getOrgID(r.Context())
userID := getUserID(r.Context())
// If a default already exists, return it.
if id := s.findDefaultWalletID(r.Context(), orgID, userID); id != "" {
wal, _ := orm.Get[db.Wallet](s.db.ORM, id)
writeJSON(w, http.StatusOK, toWalletResponse(wal, true))
return
}
// Otherwise, delegate to the spec wallet-create flow.
s.handleMpcCreateWallet(w, r)
}
// --- /v1/mpc/wallets/sweep ---
func (s *Server) handleMpcSweepWallet(w http.ResponseWriter, r *http.Request) {
orgID := getOrgID(r.Context())
userID := getUserID(r.Context())
var req struct {
FromWalletID string `json:"fromWalletId"`
ToAddress string `json:"toAddress"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.FromWalletID == "" || req.ToAddress == "" {
writeError(w, http.StatusBadRequest, "fromWalletId and toAddress are required")
return
}
wal, err := orm.Get[db.Wallet](s.db.ORM, req.FromWalletID)
if err != nil || wal.OrgID != orgID {
writeError(w, http.StatusNotFound, "wallet not found")
return
}
// Create a sweep operation (Transaction with txType=sweep). Returns the
// `MpcTransaction` shape the spec expects.
tx := orm.New[db.Transaction](s.db.ORM)
tx.OrgID = orgID
walletID := wal.Id()
tx.WalletID = &walletID
tx.TxType = "sweep"
tx.Chain = "evm"
tx.ToAddress = &req.ToAddress
tx.Status = "pending"
tx.InitiatedBy = nilIfEmpty(userID)
if err := tx.Create(); err != nil {
writeError(w, http.StatusInternalServerError, "failed to initiate sweep")
return
}
writeJSON(w, http.StatusAccepted, map[string]interface{}{
"txId": tx.Id(),
"status": tx.Status,
"createdAt": tx.CreatedAt,
})
}
// --- /v1/mpc/wallets/balances + /v1/mpc/balances/{address} ---
func (s *Server) handleMpcWalletBalances(w http.ResponseWriter, r *http.Request) {
orgID := getOrgID(r.Context())
userID := getUserID(r.Context())
role := getRole(r.Context())
q := orm.TypedQuery[db.Wallet](s.db.ORM).Filter("orgId=", orgID).Limit(200)
if role != "owner" && role != "admin" && userID != "" {
q = q.Filter("createdBy=", userID)
}
wallets, err := q.GetAll(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "database error")
return
}
items := make([]map[string]interface{}, 0, len(wallets))
for _, wal := range wallets {
addr := ""
if wal.EthAddress != nil {
addr = *wal.EthAddress
}
items = append(items, map[string]interface{}{
"walletId": wal.Id(),
"address": addr,
"balances": []any{},
})
}
writeJSON(w, http.StatusOK, map[string]interface{}{"wallets": items})
}
func (s *Server) handleMpcBalancesByAddress(w http.ResponseWriter, r *http.Request) {
addr := urlParam(r, "address")
writeJSON(w, http.StatusOK, map[string]interface{}{
"address": addr,
"balances": []any{},
})
}
// --- /v1/mpc/crypto/wallet/{asset} ---
func (s *Server) handleMpcCryptoWallet(w http.ResponseWriter, r *http.Request) {
orgID := getOrgID(r.Context())
userID := getUserID(r.Context())
asset := urlParam(r, "asset")
id := s.findDefaultWalletID(r.Context(), orgID, userID)
if id == "" {
writeError(w, http.StatusNotFound, "no wallet for asset")
return
}
wal, err := orm.Get[db.Wallet](s.db.ORM, id)
if err != nil {
writeError(w, http.StatusNotFound, "no wallet for asset")
return
}
addr := ""
network := ""
switch asset {
case "btc", "BTC":
if wal.BtcAddress != nil {
addr = *wal.BtcAddress
}
network = "bitcoin"
case "sol", "SOL":
if wal.SolAddress != nil {
addr = *wal.SolAddress
}
network = "solana"
default:
if wal.EthAddress != nil {
addr = *wal.EthAddress
}
network = "ethereum"
}
if addr == "" {
writeError(w, http.StatusNotFound, "no wallet for asset")
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"asset": asset,
"address": addr,
"network": network,
"walletId": wal.Id(),
})
}
// --- /v1/mpc/sign + /v1/mpc/settlement/sign ---
type signRequest struct {
Message string `json:"message"`
Encoding string `json:"encoding,omitempty"`
WalletID string `json:"walletId,omitempty"`
SessionID string `json:"sessionId,omitempty"`
Value string `json:"value,omitempty"`
}
func (s *Server) handleMpcSignDefault(w http.ResponseWriter, r *http.Request) {
orgID := getOrgID(r.Context())
userID := getUserID(r.Context())
var req signRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Message == "" {
writeError(w, http.StatusBadRequest, "message is required")
return
}
walletID := req.WalletID
if walletID == "" {
walletID = s.findDefaultWalletID(r.Context(), orgID, userID)
}
if walletID == "" {
writeError(w, http.StatusBadRequest, "no default wallet; specify walletId")
return
}
wal, err := orm.Get[db.Wallet](s.db.ORM, walletID)
if err != nil || wal.OrgID != orgID {
writeError(w, http.StatusNotFound, "wallet not found")
return
}
// Enforce session validity if a session is presented OR if org policy
// requires one (no session + explicit sessionId → 403).
if req.SessionID != "" {
if _, err := s.consumeSessionForSign(r.Context(), orgID, walletID, userID, req.SessionID, req.Value); err != nil {
writeHTTPError(w, err)
return
}
}
payload := decodeMessage(req.Message, req.Encoding)
result, err := s.mpc.TriggerSign(orgID, wal.WalletID, payload)
if err != nil {
writeError(w, http.StatusInternalServerError, "signing failed: "+err.Error())
return
}
s.recordMpcAudit(r.Context(), orgID, userID, "operation.sign", "wallet", walletID)
writeJSON(w, http.StatusOK, map[string]string{
"signature": result.Signature,
"r": result.R,
"s": result.S,
})
}
func (s *Server) handleMpcSignSettlement(w http.ResponseWriter, r *http.Request) {
orgID := getOrgID(r.Context())
userID := getUserID(r.Context())
var req struct {
WalletID string `json:"walletId"`
Payload string `json:"payload"`
Reason string `json:"reason"`
ReferenceID string `json:"referenceId,omitempty"`
IdempotencyKey string `json:"idempotencyKey,omitempty"`
SessionID string `json:"sessionId,omitempty"`
Value string `json:"value,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.WalletID == "" || req.Payload == "" || req.Reason == "" {
writeError(w, http.StatusBadRequest, "walletId, payload, reason are required")
return
}
switch req.Reason {
case "trade_settlement", "deposit", "withdrawal", "issuance", "cancellation":
default:
writeError(w, http.StatusBadRequest, "invalid reason")
return
}
wal, err := orm.Get[db.Wallet](s.db.ORM, req.WalletID)
if err != nil || wal.OrgID != orgID {
writeError(w, http.StatusNotFound, "wallet not found")
return
}
if req.SessionID != "" {
if _, err := s.consumeSessionForSign(r.Context(), orgID, req.WalletID, userID, req.SessionID, req.Value); err != nil {
writeHTTPError(w, err)
return
}
}
payload := decodeMessage(req.Payload, "hex")
result, err := s.mpc.TriggerSign(orgID, wal.WalletID, payload)
if err != nil {
writeError(w, http.StatusInternalServerError, "settlement signing failed: "+err.Error())
return
}
s.recordMpcAudit(r.Context(), orgID, userID, "operation.sign_settlement", "wallet", req.WalletID)
writeJSON(w, http.StatusOK, map[string]string{
"signature": result.Signature,
"r": result.R,
"s": result.S,
})
}
// --- /v1/mpc/biometric/{enroll,status} ---
func (s *Server) handleBiometricEnroll(w http.ResponseWriter, r *http.Request) {
orgID := getOrgID(r.Context())
userID := getUserID(r.Context())
var req struct {
Template string `json:"template"`
Modality string `json:"modality"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Template == "" {
writeError(w, http.StatusBadRequest, "template is required")
return
}
if req.Modality == "" {
req.Modality = "face"
}
if req.Modality != "face" && req.Modality != "fingerprint" {
writeError(w, http.StatusBadRequest, "modality must be face or fingerprint")
return
}
// Record enrollment via DeviceEnrollment with biometricType=modality.
enroll := orm.New[db.DeviceEnrollment](s.db.ORM)
enroll.OrgID = orgID
enroll.UserID = userID
enroll.DeviceID = "biometric-" + req.Modality
enroll.DeviceType = "biometric"
enroll.BiometricType = req.Modality
enroll.PublicKey = req.Template
enroll.Status = "active"
if err := enroll.Create(); err != nil {
writeError(w, http.StatusInternalServerError, "failed to enroll biometric: "+err.Error())
return
}
s.recordMpcAudit(r.Context(), orgID, userID, "biometric.enroll", "biometric", enroll.Id())
writeJSON(w, http.StatusCreated, map[string]string{
"enrollmentId": enroll.Id(),
"status": "enrolled",
})
}
func (s *Server) handleBiometricStatus(w http.ResponseWriter, r *http.Request) {
orgID := getOrgID(r.Context())
userID := getUserID(r.Context())
enrolls, err := orm.TypedQuery[db.DeviceEnrollment](s.db.ORM).
Filter("orgId=", orgID).
Filter("userId=", userID).
Filter("deviceType=", "biometric").
Order("-createdAt").
Limit(1).
GetAll(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "database error")
return
}
if len(enrolls) == 0 {
writeJSON(w, http.StatusOK, map[string]interface{}{
"userId": userID,
"status": "not_enrolled",
})
return
}
e := enrolls[0]
writeJSON(w, http.StatusOK, map[string]interface{}{
"userId": userID,
"enrollmentId": e.Id(),
"status": e.Status,
"enrolledAt": e.CreatedAt,
})
}
// --- /v1/mpc/webauthn/{challenge,verify} — spec-named aliases ---
//
// The spec uses `/v1/mpc/webauthn/challenge` + `/v1/mpc/webauthn/verify`.
// We already expose `handleRegisterWebAuthnBegin` and `handleVerifyWebAuthn`;
// just route the spec paths to those handlers.
// decodeMessage decodes the spec-declared `encoding` (hex|base64) into raw
// bytes. Unknown encodings fall through to raw string bytes.
func decodeMessage(msg, encoding string) []byte {
switch encoding {
case "", "hex":
b, err := hexDecodeSafe(msg)
if err == nil {
return b
}
return []byte(msg)
case "base64":
b, err := base64DecodeSafe(msg)
if err == nil {
return b
}
return []byte(msg)
default:
return []byte(msg)
}
}
+406
View File
@@ -0,0 +1,406 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/hanzoai/orm"
"github.com/luxfi/mpc/pkg/db"
)
// --- /v1/mpc/operations ---
//
// Unified `Operation` view (sign/send/mint/burn/transfer/contract_call) over
// the existing `db.Transaction` table. No new operations table — one table,
// one view, `txType` becomes the discriminator.
//
// Status mapping between Transaction.Status and Operation.status:
//
// pending_approval → pending_approval
// approved → approved
// signing, signed → signed
// rejected → rejected
// failed, reverted → failed
// expired → expired
// (everything else) → signed (as long as there's a signature)
type operationResponse struct {
OperationID string `json:"operationId"`
WalletID string `json:"walletId"`
Kind string `json:"kind"`
Payload map[string]interface{} `json:"payload,omitempty"`
Status string `json:"status"`
Approvers []string `json:"approvers,omitempty"`
Approvals []operationApproval `json:"approvals,omitempty"`
RejectionReason string `json:"rejectionReason,omitempty"`
Result map[string]string `json:"result,omitempty"`
CreatedAt time.Time `json:"createdAt"`
CompletedAt *time.Time `json:"completedAt,omitempty"`
}
type operationApproval struct {
ApproverID string `json:"approverId"`
ApprovedAt time.Time `json:"approvedAt"`
Notes string `json:"notes,omitempty"`
}
func txToOperation(t *db.Transaction) operationResponse {
walletID := ""
if t.WalletID != nil {
walletID = *t.WalletID
}
kind := t.TxType
if kind == "" {
kind = "sign"
}
// Canonicalize kind → spec enum.
switch kind {
case "sweep":
kind = "transfer"
case "mint", "burn", "transfer", "send", "contract_call", "sign":
default:
kind = "sign"
}
status := mapTxStatus(t.Status)
payload := map[string]interface{}{}
if t.ToAddress != nil {
payload["toAddress"] = *t.ToAddress
}
if t.Amount != nil {
payload["amount"] = *t.Amount
}
if t.Token != nil {
payload["token"] = *t.Token
}
if t.Chain != "" {
payload["chain"] = t.Chain
}
if len(t.RawTx) > 0 {
payload["rawTx"] = "0x" + hexString(t.RawTx)
}
approvals := make([]operationApproval, 0, len(t.ApprovedBy))
for _, id := range t.ApprovedBy {
approvals = append(approvals, operationApproval{
ApproverID: id,
ApprovedAt: t.UpdatedAt,
})
}
result := map[string]string{}
if t.SignatureR != nil && t.SignatureS != nil {
result["signature"] = "0x" + *t.SignatureR + *t.SignatureS
} else if t.SignatureEdDSA != nil {
result["signature"] = *t.SignatureEdDSA
}
if t.TxHash != nil {
result["txHash"] = *t.TxHash
}
rejection := ""
if t.RejectionReason != nil {
rejection = *t.RejectionReason
}
var completed *time.Time
if t.SignedAt != nil {
completed = t.SignedAt
} else if t.FinalizedAt != nil {
completed = t.FinalizedAt
}
return operationResponse{
OperationID: t.Id(),
WalletID: walletID,
Kind: kind,
Payload: payload,
Status: status,
Approvers: t.ApprovedBy,
Approvals: approvals,
RejectionReason: rejection,
Result: result,
CreatedAt: t.CreatedAt,
CompletedAt: completed,
}
}
func mapTxStatus(s string) string {
switch s {
case "pending_approval":
return "pending_approval"
case "approved":
return "approved"
case "signing", "signed", "broadcast", "confirming", "finalized":
return "signed"
case "rejected":
return "rejected"
case "failed", "reverted":
return "failed"
case "expired":
return "expired"
default:
return s
}
}
func hexString(b []byte) string {
const alphabet = "0123456789abcdef"
out := make([]byte, len(b)*2)
for i, v := range b {
out[i*2] = alphabet[v>>4]
out[i*2+1] = alphabet[v&0x0f]
}
return string(out)
}
func (s *Server) handleListOperations(w http.ResponseWriter, r *http.Request) {
orgID := getOrgID(r.Context())
q := orm.TypedQuery[db.Transaction](s.db.ORM).
Filter("orgId=", orgID).
Order("-createdAt")
walletID := r.URL.Query().Get("walletId")
if walletID != "" {
q = q.Filter("walletId=", walletID)
}
statusFilter := r.URL.Query().Get("status")
if statusFilter != "" {
// Spec statuses → Transaction statuses (best-effort).
q = q.Filter("status=", reverseMapStatus(statusFilter))
}
perPage := parseIntDefault(r.URL.Query().Get("perPage"), 50)
page := parseIntDefault(r.URL.Query().Get("page"), 1)
q = q.Limit(perPage)
txs, err := q.GetAll(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "database error")
return
}
items := make([]operationResponse, 0, len(txs))
for _, t := range txs {
items = append(items, txToOperation(t))
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"items": items,
"page": page,
"totalItems": len(items),
})
}
func reverseMapStatus(s string) string {
switch s {
case "signed":
return "signed"
case "pending_approval", "approved", "rejected", "failed", "expired":
return s
}
return s
}
func (s *Server) handleGetOperation(w http.ResponseWriter, r *http.Request) {
orgID := getOrgID(r.Context())
opID := urlParam(r, "operationId")
t, err := orm.Get[db.Transaction](s.db.ORM, opID)
if err != nil || t.OrgID != orgID {
writeError(w, http.StatusNotFound, "operation not found")
return
}
writeJSON(w, http.StatusOK, txToOperation(t))
}
// handleApproveOperation enforces N-of-M approval per wallet policy.
// Approver identity is taken from the JWT `sub` (userID) — not from body.
func (s *Server) handleApproveOperation(w http.ResponseWriter, r *http.Request) {
orgID := getOrgID(r.Context())
userID := getUserID(r.Context())
opID := urlParam(r, "operationId")
var req struct {
Notes string `json:"notes"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
t, err := orm.Get[db.Transaction](s.db.ORM, opID)
if err != nil || t.OrgID != orgID {
writeError(w, http.StatusNotFound, "operation not found")
return
}
if t.Status != "pending_approval" {
writeError(w, http.StatusConflict, "operation not in pending_approval state")
return
}
for _, id := range t.ApprovedBy {
if id == userID {
writeError(w, http.StatusConflict, "already approved")
return
}
}
if t.InitiatedBy != nil && *t.InitiatedBy == userID && len(t.ApprovedBy) == 0 {
writeError(w, http.StatusForbidden, "initiator cannot self-approve")
return
}
// Required approvers — from wallet policy if any, else 1.
required := 1
if t.WalletID != nil {
policies, _ := s.loadPolicies(r.Context(), orgID, nil)
amt := ""
if t.Amount != nil {
amt = *t.Amount
}
to := ""
if t.ToAddress != nil {
to = *t.ToAddress
}
decision := evaluateTransaction(amt, t.Chain, to, policies)
if decision.RequiredApprovers > required {
required = decision.RequiredApprovers
}
}
t.ApprovedBy = append(t.ApprovedBy, userID)
if err := t.Update(); err != nil {
writeError(w, http.StatusInternalServerError, "failed to approve operation")
return
}
if len(t.ApprovedBy) < required {
// Threshold not yet reached — remain pending_approval.
s.recordMpcAudit(r.Context(), orgID, userID, "operation.approve", "operation", opID)
writeJSON(w, http.StatusOK, txToOperation(t))
return
}
detail := fmt.Sprintf("quorum reached: %d/%d approvals", len(t.ApprovedBy), required)
t.RecordTransition("approved", detail, &userID)
t.Update()
go s.signAndBroadcast(opID, orgID)
s.recordMpcAudit(r.Context(), orgID, userID, "operation.approve", "operation", opID)
writeJSON(w, http.StatusOK, txToOperation(t))
}
func (s *Server) handleRejectOperation(w http.ResponseWriter, r *http.Request) {
orgID := getOrgID(r.Context())
userID := getUserID(r.Context())
opID := urlParam(r, "operationId")
var req struct {
Reason string `json:"reason"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Reason == "" {
writeError(w, http.StatusBadRequest, "reason is required")
return
}
t, err := orm.Get[db.Transaction](s.db.ORM, opID)
if err != nil || t.OrgID != orgID {
writeError(w, http.StatusNotFound, "operation not found")
return
}
if t.Status != "pending_approval" {
writeError(w, http.StatusConflict, "operation not in pending_approval state")
return
}
t.RecordTransition("rejected", "rejected: "+req.Reason, &userID)
t.RejectedBy = nilIfEmpty(userID)
t.RejectionReason = nilIfEmpty(req.Reason)
if err := t.Update(); err != nil {
writeError(w, http.StatusInternalServerError, "failed to reject operation")
return
}
s.recordMpcAudit(r.Context(), orgID, userID, "operation.reject", "operation", opID)
writeJSON(w, http.StatusOK, txToOperation(t))
}
// --- /v1/mpc/audit ---
func (s *Server) handleMpcListAudit(w http.ResponseWriter, r *http.Request) {
orgID := getOrgID(r.Context())
q := orm.TypedQuery[db.AuditEntry](s.db.ORM).
Filter("orgId=", orgID).
Order("-createdAt")
if walletID := r.URL.Query().Get("walletId"); walletID != "" {
q = q.Filter("resourceId=", walletID)
}
if actor := r.URL.Query().Get("actorId"); actor != "" {
q = q.Filter("userId=", actor)
}
if action := r.URL.Query().Get("action"); action != "" {
q = q.Filter("action=", action)
}
perPage := parseIntDefault(r.URL.Query().Get("perPage"), 100)
page := parseIntDefault(r.URL.Query().Get("page"), 1)
q = q.Limit(perPage)
entries, err := q.GetAll(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "database error")
return
}
items := make([]map[string]interface{}, 0, len(entries))
for _, e := range entries {
item := map[string]interface{}{
"entryId": e.Id(),
"tenantId": e.OrgID,
"action": e.Action,
"timestamp": e.CreatedAt,
}
if e.UserID != nil {
item["actorId"] = *e.UserID
}
if e.ResourceType != nil {
item["subjectType"] = *e.ResourceType
}
if e.ResourceID != nil {
item["subjectId"] = *e.ResourceID
}
if e.IPAddress != nil {
item["ip"] = *e.IPAddress
}
items = append(items, item)
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"items": items,
"page": page,
"totalItems": len(items),
})
}
// --- /v1/mpc/policies — thin wrapper around existing policy handlers ---
//
// Spec shape:
// GET /v1/mpc/policies
// POST /v1/mpc/policies
// GET /v1/mpc/policies/{policyId}
// PATCH /v1/mpc/policies/{policyId}
// DELETE /v1/mpc/policies/{policyId}
//
// We reuse the existing CRUD handlers (handleListPolicies, handleCreatePolicy,
// handleUpdatePolicy, handleDeletePolicy) and add a thin get-by-id.
func (s *Server) handleGetPolicy(w http.ResponseWriter, r *http.Request) {
orgID := getOrgID(r.Context())
policyID := urlParam(r, "policyId")
policy, err := orm.Get[db.Policy](s.db.ORM, policyID)
if err != nil || policy.OrgID != orgID {
writeError(w, http.StatusNotFound, "policy not found")
return
}
writeJSON(w, http.StatusOK, policy)
}
func parseIntDefault(s string, def int) int {
if s == "" {
return def
}
i, err := strconv.Atoi(strings.TrimSpace(s))
if err != nil || i <= 0 {
return def
}
return i
}
+8 -2
View File
@@ -65,7 +65,10 @@ func (s *Server) handleCreatePolicy(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleUpdatePolicy(w http.ResponseWriter, r *http.Request) {
orgID := getOrgID(r.Context())
policyID := urlParam(r, "id")
policyID := urlParam(r, "policyId")
if policyID == "" {
policyID = urlParam(r, "id")
}
var req struct {
Name *string `json:"name,omitempty"`
@@ -114,7 +117,10 @@ func (s *Server) handleUpdatePolicy(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleDeletePolicy(w http.ResponseWriter, r *http.Request) {
orgID := getOrgID(r.Context())
policyID := urlParam(r, "id")
policyID := urlParam(r, "policyId")
if policyID == "" {
policyID = urlParam(r, "id")
}
policy, err := orm.Get[db.Policy](s.db.ORM, policyID)
if err != nil || policy.OrgID != orgID {
+341
View File
@@ -0,0 +1,341 @@
package api
import (
"context"
"encoding/json"
"math/big"
"net/http"
"time"
"github.com/hanzoai/orm"
"github.com/luxfi/mpc/pkg/db"
)
// --- Sessions: /v1/mpc/wallets/{id}/sessions ---
//
// A session is a time-bounded signing grant for an MPC wallet. The caller
// (user or service principal) may sign up to OperationLimit operations with
// cumulative value up to ValueLimit, until ExpiresAt. Enforcement happens in
// handleSignWithDefaultWallet / handleSignSettlement via consumeSessionForSign.
// sessionResponse is the JSON shape specified in mpc.yaml.
type sessionResponse struct {
SessionID string `json:"sessionId"`
WalletID string `json:"walletId"`
GrantedTo string `json:"grantedTo,omitempty"`
Scopes []string `json:"scopes,omitempty"`
ValueLimit *string `json:"valueLimit,omitempty"`
OperationLimit *int `json:"operationLimit,omitempty"`
Status string `json:"status"`
CreatedAt time.Time `json:"createdAt"`
ExpiresAt time.Time `json:"expiresAt"`
RevokedAt *time.Time `json:"revokedAt,omitempty"`
}
func toSessionResponse(s *db.Session) sessionResponse {
return sessionResponse{
SessionID: s.Id(),
WalletID: s.WalletID,
GrantedTo: s.GrantedTo,
Scopes: s.Scopes,
ValueLimit: s.ValueLimit,
OperationLimit: s.OperationLimit,
Status: effectiveSessionStatus(s),
CreatedAt: s.CreatedAt,
ExpiresAt: s.ExpiresAt,
RevokedAt: s.RevokedAt,
}
}
// effectiveSessionStatus reports expired for any session whose wall-clock
// expiry or limits have elapsed. Stored `Status` is authoritative for
// revoked/pending_approval.
func effectiveSessionStatus(s *db.Session) string {
if s.Status == "revoked" || s.Status == "pending_approval" {
return s.Status
}
if time.Now().After(s.ExpiresAt) {
return "expired"
}
if s.OperationLimit != nil && s.OperationsUsed >= *s.OperationLimit {
return "expired"
}
return s.Status
}
func (s *Server) handleListWalletSessions(w http.ResponseWriter, r *http.Request) {
orgID := getOrgID(r.Context())
walletID := urlParam(r, "id")
// Verify wallet ownership
wallet, err := orm.Get[db.Wallet](s.db.ORM, walletID)
if err != nil || wallet.OrgID != orgID {
writeError(w, http.StatusNotFound, "wallet not found")
return
}
q := orm.TypedQuery[db.Session](s.db.ORM).
Filter("orgId=", orgID).
Filter("walletId=", walletID).
Order("-createdAt").
Limit(200)
sessions, err := q.GetAll(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "database error")
return
}
statusFilter := r.URL.Query().Get("status")
items := make([]sessionResponse, 0, len(sessions))
for _, sess := range sessions {
resp := toSessionResponse(sess)
if statusFilter != "" && resp.Status != statusFilter {
continue
}
items = append(items, resp)
}
writeJSON(w, http.StatusOK, map[string]interface{}{"items": items})
}
func (s *Server) handleCreateWalletSession(w http.ResponseWriter, r *http.Request) {
orgID := getOrgID(r.Context())
userID := getUserID(r.Context())
walletID := urlParam(r, "id")
wallet, err := orm.Get[db.Wallet](s.db.ORM, walletID)
if err != nil || wallet.OrgID != orgID {
writeError(w, http.StatusNotFound, "wallet not found")
return
}
var req struct {
GrantedTo string `json:"grantedTo,omitempty"`
Scopes []string `json:"scopes"`
ValueLimit *string `json:"valueLimit,omitempty"`
OperationLimit *int `json:"operationLimit,omitempty"`
ExpiresAt time.Time `json:"expiresAt"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if len(req.Scopes) == 0 {
writeError(w, http.StatusBadRequest, "scopes is required")
return
}
if req.ExpiresAt.IsZero() {
writeError(w, http.StatusBadRequest, "expiresAt is required")
return
}
if !req.ExpiresAt.After(time.Now()) {
writeError(w, http.StatusBadRequest, "expiresAt must be in the future")
return
}
for _, scope := range req.Scopes {
switch scope {
case "sign", "authorize", "read":
default:
writeError(w, http.StatusBadRequest, "unknown scope: "+scope)
return
}
}
if req.ValueLimit != nil {
if _, ok := new(big.Int).SetString(*req.ValueLimit, 10); !ok {
writeError(w, http.StatusBadRequest, "valueLimit must be a base-10 integer string")
return
}
}
sess := orm.New[db.Session](s.db.ORM)
sess.OrgID = orgID
sess.WalletID = walletID
sess.GrantedTo = req.GrantedTo
if sess.GrantedTo == "" {
sess.GrantedTo = userID
}
sess.Scopes = req.Scopes
sess.ValueLimit = req.ValueLimit
sess.ValueAccum = "0"
sess.OperationLimit = req.OperationLimit
sess.OperationsUsed = 0
sess.Status = "active"
sess.ExpiresAt = req.ExpiresAt
sess.CreatedBy = nilIfEmpty(userID)
if err := sess.Create(); err != nil {
writeError(w, http.StatusInternalServerError, "failed to create session: "+err.Error())
return
}
s.recordMpcAudit(r.Context(), orgID, userID, "session.create", "session", sess.Id())
writeJSON(w, http.StatusCreated, toSessionResponse(sess))
}
func (s *Server) handleGetWalletSession(w http.ResponseWriter, r *http.Request) {
orgID := getOrgID(r.Context())
walletID := urlParam(r, "id")
sessionID := urlParam(r, "sessionId")
sess, err := orm.Get[db.Session](s.db.ORM, sessionID)
if err != nil || sess.OrgID != orgID || sess.WalletID != walletID {
writeError(w, http.StatusNotFound, "session not found")
return
}
writeJSON(w, http.StatusOK, toSessionResponse(sess))
}
func (s *Server) handleRevokeWalletSession(w http.ResponseWriter, r *http.Request) {
orgID := getOrgID(r.Context())
userID := getUserID(r.Context())
walletID := urlParam(r, "id")
sessionID := urlParam(r, "sessionId")
sess, err := orm.Get[db.Session](s.db.ORM, sessionID)
if err != nil || sess.OrgID != orgID || sess.WalletID != walletID {
writeError(w, http.StatusNotFound, "session not found")
return
}
if sess.Status != "revoked" {
now := time.Now()
sess.Status = "revoked"
sess.RevokedAt = &now
sess.RevokedBy = nilIfEmpty(userID)
if err := sess.Update(); err != nil {
writeError(w, http.StatusInternalServerError, "failed to revoke session")
return
}
s.recordMpcAudit(r.Context(), orgID, userID, "session.revoke", "session", sessionID)
}
w.WriteHeader(http.StatusNoContent)
}
// consumeSessionForSign enforces session validity + limits for a signing
// attempt. Returns the session used (for audit) or nil if no session was
// presented — no-session sign is allowed only when the caller's role is
// owner/admin/signer/api and no active session exists for that principal.
//
// If `sessionID` is non-empty it is required to match; otherwise the most
// recently created active session for (orgID, walletID, principal) is used.
//
// On success the session is updated transactionally: OperationsUsed++ and
// ValueAccum += value. On failure returns an *httpError suitable to surface
// as 403.
func (s *Server) consumeSessionForSign(
ctx context.Context,
orgID, walletID, principal, sessionID, value string,
) (*db.Session, error) {
if sessionID == "" {
// Look up the most recent active session for this principal+wallet.
q := orm.TypedQuery[db.Session](s.db.ORM).
Filter("orgId=", orgID).
Filter("walletId=", walletID).
Filter("grantedTo=", principal).
Filter("status=", "active").
Order("-createdAt").
Limit(1)
sessions, err := q.GetAll(ctx)
if err != nil || len(sessions) == 0 {
return nil, nil // no session — caller decides whether to allow
}
sessionID = sessions[0].Id()
}
sess, err := orm.Get[db.Session](s.db.ORM, sessionID)
if err != nil {
return nil, &httpError{code: http.StatusForbidden, msg: "session not found"}
}
if sess.OrgID != orgID || sess.WalletID != walletID {
return nil, &httpError{code: http.StatusForbidden, msg: "session mismatch"}
}
if sess.Status == "revoked" {
return nil, &httpError{code: http.StatusForbidden, msg: "session revoked"}
}
if time.Now().After(sess.ExpiresAt) {
return nil, &httpError{code: http.StatusForbidden, msg: "session expired"}
}
if sess.GrantedTo != "" && sess.GrantedTo != principal {
return nil, &httpError{code: http.StatusForbidden, msg: "session granted to different principal"}
}
scopeOK := false
for _, scope := range sess.Scopes {
if scope == "sign" {
scopeOK = true
break
}
}
if !scopeOK {
return nil, &httpError{code: http.StatusForbidden, msg: "session does not grant sign scope"}
}
if sess.OperationLimit != nil && sess.OperationsUsed >= *sess.OperationLimit {
return nil, &httpError{code: http.StatusForbidden, msg: "session operation limit reached"}
}
// Value accumulation
if sess.ValueLimit != nil && value != "" {
limit, ok := new(big.Int).SetString(*sess.ValueLimit, 10)
used, _ := new(big.Int).SetString(sess.ValueAccum, 10)
if used == nil {
used = new(big.Int)
}
amt, aok := new(big.Int).SetString(value, 10)
if !ok || !aok {
return nil, &httpError{code: http.StatusForbidden, msg: "session value accounting error"}
}
next := new(big.Int).Add(used, amt)
if next.Cmp(limit) > 0 {
return nil, &httpError{code: http.StatusForbidden, msg: "session value limit exceeded"}
}
sess.ValueAccum = next.String()
}
sess.OperationsUsed++
if err := sess.Update(); err != nil {
return nil, &httpError{code: http.StatusInternalServerError, msg: "failed to update session"}
}
return sess, nil
}
type httpError struct {
code int
msg string
}
func (e *httpError) Error() string { return e.msg }
// writeHTTPError renders an *httpError onto the response writer. Passthrough
// for plain errors maps to 500.
func writeHTTPError(w http.ResponseWriter, err error) {
if e, ok := err.(*httpError); ok {
writeError(w, e.code, e.msg)
return
}
writeError(w, http.StatusInternalServerError, err.Error())
}
// startSessionReaper periodically marks expired sessions as expired.
// This is the background sweep mandated by the MPC spec.
func (s *Server) startSessionReaper(ctx context.Context, interval time.Duration) {
go func() {
t := time.NewTicker(interval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
sessions, err := orm.TypedQuery[db.Session](s.db.ORM).
Filter("status=", "active").
Limit(500).
GetAll(ctx)
if err != nil || len(sessions) == 0 {
continue
}
now := time.Now()
for _, sess := range sessions {
if now.After(sess.ExpiresAt) {
sess.Status = "expired"
sess.Update()
}
}
}
}
}()
}
+69 -19
View File
@@ -252,23 +252,14 @@ func NewServer(database *db.Database, mpcBackend MPCBackend, jwtSecret string, o
r.Post("/wallets/{id}/reshare", s.handleReshareWallet)
})
// Transactions — signers+ can create/approve; viewers can read
r.Get("/transactions", s.handleListTransactions)
r.Get("/transactions/{id}", s.handleGetTransaction)
// Transactions (legacy create/approve/reject plumbing kept as
// it is the substrate for the unified /v1/mpc/operations view).
// The read surface for operations moves to /v1/mpc/operations
// below; direct POST /v1/transactions retained as internal entry
// point for intents/payments which wire their own flows.
r.Group(func(r chi.Router) {
r.Use(requireRole("owner", "admin", "signer", "api"))
r.Post("/transactions", s.handleCreateTransaction)
r.Post("/transactions/{id}/approve", s.handleApproveTransaction)
r.Post("/transactions/{id}/reject", s.handleRejectTransaction)
})
// Policies — owner/admin only
r.Group(func(r chi.Router) {
r.Use(requireRole("owner", "admin"))
r.Get("/policies", s.handleListPolicies)
r.Post("/policies", s.handleCreatePolicy)
r.Patch("/policies/{id}", s.handleUpdatePolicy)
r.Delete("/policies/{id}", s.handleDeletePolicy)
})
// Whitelist — owner/admin only
@@ -368,11 +359,7 @@ func NewServer(database *db.Database, mpcBackend MPCBackend, jwtSecret string, o
r.Get("/wallets/{id}/backup", s.handleGetWalletBackup)
})
// Audit — owner/admin only
r.Group(func(r chi.Router) {
r.Use(requireRole("owner", "admin"))
r.Get("/audit", s.handleListAudit)
})
// Legacy audit route removed — moved to /v1/mpc/audit.
// Validator key management (KMS) — owner/admin only
r.Group(func(r chi.Router) {
@@ -383,6 +370,69 @@ func NewServer(database *db.Database, mpcBackend MPCBackend, jwtSecret string, o
// Status — any authenticated (including API keys)
r.Get("/status", s.handleStatus)
r.Get("/info", s.handleInfo)
// ── /v1/mpc/* — Liquidity MPC spec surface
// Canonical routes defined in ~/work/liquidity/openapi/mpc.yaml.
// No alias paths, no backwards compatibility.
r.Route("/mpc", func(r chi.Router) {
// Wallets
r.Get("/wallets", s.handleMpcListWallets)
r.Post("/wallets", s.handleMpcCreateWallet)
r.Get("/wallets/balances", s.handleMpcWalletBalances)
r.Post("/wallets/sweep", s.handleMpcSweepWallet)
r.Get("/wallets/{id}", s.handleMpcGetWallet)
r.Patch("/wallets/{id}/default", s.handleMpcSetDefaultWallet)
// Default wallet
r.Get("/wallet", s.handleMpcGetDefaultWallet)
r.Post("/wallet", s.handleMpcCreateDefaultWallet)
// Balances + crypto
r.Get("/balances/{address}", s.handleMpcBalancesByAddress)
r.Get("/crypto/wallet/{asset}", s.handleMpcCryptoWallet)
// Signing
r.With(RateLimitMiddleware(20)).Post("/sign", s.handleMpcSignDefault)
r.With(requireRole("owner", "admin", "signer", "api"), RateLimitMiddleware(20)).
Post("/settlement/sign", s.handleMpcSignSettlement)
// WebAuthn (spec-named aliases to the existing handlers)
r.Post("/webauthn/challenge", s.handleRegisterWebAuthnBegin)
r.Post("/webauthn/verify", s.handleVerifyWebAuthn)
// Biometric
r.Post("/biometric/enroll", s.handleBiometricEnroll)
r.Get("/biometric/status", s.handleBiometricStatus)
// Sessions (wallet-scoped)
r.Route("/wallets/{id}/sessions", func(r chi.Router) {
r.Get("/", s.handleListWalletSessions)
r.Post("/", s.handleCreateWalletSession)
r.Get("/{sessionId}", s.handleGetWalletSession)
r.Delete("/{sessionId}", s.handleRevokeWalletSession)
})
// Operations (unified view over Transaction)
r.Get("/operations", s.handleListOperations)
r.Get("/operations/{operationId}", s.handleGetOperation)
r.With(requireRole("owner", "admin", "signer", "api")).
Post("/operations/{operationId}/approve", s.handleApproveOperation)
r.With(requireRole("owner", "admin", "signer", "api")).
Post("/operations/{operationId}/reject", s.handleRejectOperation)
// Policies — owner/admin
r.Group(func(r chi.Router) {
r.Use(requireRole("owner", "admin"))
r.Get("/policies", s.handleListPolicies)
r.Post("/policies", s.handleCreatePolicy)
r.Get("/policies/{policyId}", s.handleGetPolicy)
r.Patch("/policies/{policyId}", s.handleUpdatePolicy)
r.Delete("/policies/{policyId}", s.handleDeletePolicy)
})
// Audit — owner/admin
r.With(requireRole("owner", "admin")).Get("/audit", s.handleMpcListAudit)
})
})
})
+34
View File
@@ -467,3 +467,37 @@ type PendingTrade struct {
}
func init() { orm.Register[PendingTrade]("pending-trade") }
// Session is a time-bounded signing grant for an MPC wallet.
//
// Sessions are specialized short-lived Policy records: a caller (user or
// service principal) is authorized to sign up to `OperationLimit` operations
// with cumulative value up to `ValueLimit`, until `ExpiresAt`. Every
// successful sign decrements `OperationLimit` and accumulates into
// `ValueAccum` transactionally via `ConsumeForSign`.
//
// Status flow:
//
// active → expired (clock passes ExpiresAt)
// active → revoked (DELETE)
// active → exhausted (operation/value limit hit) — reported as `expired`
//
// Spec: mpc.yaml `/v1/mpc/wallets/{id}/sessions`.
type Session struct {
orm.Model[Session]
OrgID string `json:"orgId"`
WalletID string `json:"walletId"`
GrantedTo string `json:"grantedTo"` // principal (userId or serviceId)
Scopes []string `json:"scopes"` // sign, authorize, read
ValueLimit *string `json:"valueLimit,omitempty"`
ValueAccum string `json:"valueAccum,omitempty"` // accumulated usage
OperationLimit *int `json:"operationLimit,omitempty"`
OperationsUsed int `json:"operationsUsed"`
Status string `json:"status"` // active, pending_approval, expired, revoked
ExpiresAt time.Time `json:"expiresAt"`
RevokedAt *time.Time `json:"revokedAt,omitempty"`
RevokedBy *string `json:"revokedBy,omitempty"`
CreatedBy *string `json:"createdBy,omitempty"`
}
func init() { orm.Register[Session]("mpc-session") }