mirror of
https://github.com/luxfi/mpc.git
synced 2026-09-24 00:26:24 +00:00
feat: add biometric trade approval flow with push notifications
- Trade approval service: submit, approve (WebAuthn), reject, pending
- Push notifier: APNS (iOS) + FCM (Android) for trade approval requests
- Biometric verification: Face ID/Touch ID with 30s freshness, nonce replay guard
- Custody policy engine: KYC/AML/sanctions/limits checks before co-signing
- Device store: register/revoke mobile devices with Secure Enclave keys
- API routes: POST /api/v1/trade/{submit,approve,reject}, GET pending
- Trade expiry reaper: cleanup expired approvals (>5 min)
- 17 custody tests + 33 API tests passing
This commit is contained in:
@@ -0,0 +1,326 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
|
||||
"github.com/luxfi/mpc/pkg/db"
|
||||
)
|
||||
|
||||
// handleTradeSubmit accepts a trade from the ATS and creates a pending approval.
|
||||
// The user gets a push notification on their registered device.
|
||||
// POST /api/v1/trade/submit
|
||||
func (s *Server) handleTradeSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
orgID := getOrgID(r.Context())
|
||||
|
||||
if s.tradeApproval == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "trade approval service not configured")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
UserID string `json:"user_id"`
|
||||
WalletID string `json:"wallet_id"`
|
||||
DeviceID string `json:"device_id"`
|
||||
Symbol string `json:"symbol"`
|
||||
Side string `json:"side"` // buy, sell
|
||||
Quantity string `json:"quantity"`
|
||||
Price string `json:"price"`
|
||||
TotalValue string `json:"total_value"`
|
||||
TradeID string `json:"trade_id"`
|
||||
OrderID string `json:"order_id"`
|
||||
MessageHash string `json:"message_hash"` // hex-encoded tx data to sign
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if req.UserID == "" || req.WalletID == "" || req.Symbol == "" || req.Side == "" || req.MessageHash == "" {
|
||||
writeError(w, http.StatusBadRequest, "user_id, wallet_id, symbol, side, and message_hash are required")
|
||||
return
|
||||
}
|
||||
if req.Side != "buy" && req.Side != "sell" {
|
||||
writeError(w, http.StatusBadRequest, "side must be buy or sell")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify wallet belongs to this org
|
||||
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
|
||||
}
|
||||
|
||||
trade := db.PendingTrade{
|
||||
OrgID: orgID,
|
||||
UserID: req.UserID,
|
||||
WalletID: req.WalletID,
|
||||
DeviceID: req.DeviceID,
|
||||
Symbol: req.Symbol,
|
||||
Side: req.Side,
|
||||
Quantity: req.Quantity,
|
||||
Price: req.Price,
|
||||
TotalValue: req.TotalValue,
|
||||
TradeID: req.TradeID,
|
||||
OrderID: req.OrderID,
|
||||
MessageHash: req.MessageHash,
|
||||
}
|
||||
|
||||
result, err := s.tradeApproval.SubmitForApproval(r.Context(), trade)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to submit trade: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
s.fireWebhook(r.Context(), orgID, "trade.submitted", map[string]string{
|
||||
"trade_id": result.Id(),
|
||||
"wallet_id": req.WalletID,
|
||||
"user_id": req.UserID,
|
||||
"symbol": req.Symbol,
|
||||
"side": req.Side,
|
||||
})
|
||||
|
||||
writeJSON(w, http.StatusCreated, result)
|
||||
}
|
||||
|
||||
// handleTradeApprove verifies a WebAuthn assertion (biometric) and approves the trade.
|
||||
// After approval, triggers MPC signing via the intent flow.
|
||||
// POST /api/v1/trade/approve
|
||||
func (s *Server) handleTradeApprove(w http.ResponseWriter, r *http.Request) {
|
||||
orgID := getOrgID(r.Context())
|
||||
userID := getUserID(r.Context())
|
||||
|
||||
if s.tradeApproval == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "trade approval service not configured")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
TradeID string `json:"trade_id"`
|
||||
ID string `json:"id"` // WebAuthn credential ID
|
||||
RawID string `json:"rawId"` // base64url
|
||||
Type string `json:"type"` // "public-key"
|
||||
Response struct {
|
||||
AuthenticatorData string `json:"authenticatorData"`
|
||||
ClientDataJSON string `json:"clientDataJSON"`
|
||||
Signature string `json:"signature"`
|
||||
UserHandle string `json:"userHandle"`
|
||||
} `json:"response"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if req.TradeID == "" || req.ID == "" {
|
||||
writeError(w, http.StatusBadRequest, "trade_id and credential id are required")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify WebAuthn assertion: the challenge must be SHA256(trade_id) to bind
|
||||
// the biometric approval to this specific trade.
|
||||
creds, err := orm.TypedQuery[db.WebAuthnCredential](s.db.ORM).
|
||||
Filter("userId=", userID).
|
||||
Filter("orgId=", orgID).
|
||||
Filter("webAuthnId=", req.ID).
|
||||
Filter("status=", "active").
|
||||
Limit(1).
|
||||
GetAll(r.Context())
|
||||
if err != nil || len(creds) == 0 {
|
||||
writeError(w, http.StatusUnauthorized, "no matching registered credential")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify clientDataJSON challenge matches SHA256(trade_id)
|
||||
clientData, err := base64.URLEncoding.DecodeString(req.Response.ClientDataJSON)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid clientDataJSON")
|
||||
return
|
||||
}
|
||||
var cd struct {
|
||||
Challenge string `json:"challenge"`
|
||||
Origin string `json:"origin"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
if err := json.Unmarshal(clientData, &cd); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid clientDataJSON structure")
|
||||
return
|
||||
}
|
||||
expectedChallenge := sha256.Sum256([]byte(req.TradeID))
|
||||
expectedChallengeB64 := base64.URLEncoding.EncodeToString(expectedChallenge[:])
|
||||
if cd.Challenge != expectedChallengeB64 {
|
||||
writeError(w, http.StatusBadRequest, "challenge does not match trade")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify ES256 signature against stored public key
|
||||
authData, _ := base64.URLEncoding.DecodeString(req.Response.AuthenticatorData)
|
||||
sig, _ := base64.URLEncoding.DecodeString(req.Response.Signature)
|
||||
clientDataHash := sha256.Sum256(clientData)
|
||||
signedData := append(authData, clientDataHash[:]...)
|
||||
signedHash := sha256.Sum256(signedData)
|
||||
|
||||
pubKeyBytes, _ := base64.StdEncoding.DecodeString(creds[0].PublicKey)
|
||||
if len(pubKeyBytes) >= 65 {
|
||||
x := new(big.Int).SetBytes(pubKeyBytes[1:33])
|
||||
y := new(big.Int).SetBytes(pubKeyBytes[33:65])
|
||||
pubKey := &ecdsa.PublicKey{Curve: elliptic.P256(), X: x, Y: y}
|
||||
if !ecdsa.VerifyASN1(pubKey, signedHash[:], sig) {
|
||||
writeError(w, http.StatusUnauthorized, "invalid biometric signature")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Biometric verified — approve the trade
|
||||
trade, err := s.tradeApproval.ApproveWithBiometric(r.Context(), req.TradeID, orgID, userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusConflict, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Trigger MPC signing: create an intent for the trade's message hash, then sign it
|
||||
signed := false
|
||||
var intentID string
|
||||
go func() {
|
||||
ctx := context.Background()
|
||||
if result, signErr := s.mpc.TriggerSign(orgID, trade.WalletID, []byte(trade.MessageHash)); signErr == nil {
|
||||
// Create intent record linking trade to MPC signature
|
||||
intent := orm.New[db.Intent](s.db.ORM)
|
||||
intent.OrgID = orgID
|
||||
intent.WalletID = trade.WalletID
|
||||
intent.IntentType = trade.Side
|
||||
intent.Chain = "lux"
|
||||
intent.Amount = trade.TotalValue
|
||||
intent.IntentHash = trade.MessageHash
|
||||
sig := result.Signature
|
||||
intent.Signature = &sig
|
||||
intent.Status = "signed"
|
||||
if createErr := intent.Create(); createErr == nil {
|
||||
s.tradeApproval.MarkSigned(ctx, trade.Id(), intent.Id())
|
||||
s.fireWebhook(ctx, orgID, "trade.signed", map[string]string{
|
||||
"trade_id": trade.Id(),
|
||||
"intent_id": intent.Id(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
s.fireWebhook(r.Context(), orgID, "trade.approved", map[string]string{
|
||||
"trade_id": req.TradeID,
|
||||
"approved_by": userID,
|
||||
"method": "webauthn_biometric",
|
||||
})
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"status": "approved",
|
||||
"trade_id": trade.Id(),
|
||||
"biometric": true,
|
||||
"signing_started": !signed,
|
||||
"intent_id": intentID,
|
||||
})
|
||||
}
|
||||
|
||||
// handleTradeReject rejects a pending trade.
|
||||
// POST /api/v1/trade/reject
|
||||
func (s *Server) handleTradeReject(w http.ResponseWriter, r *http.Request) {
|
||||
orgID := getOrgID(r.Context())
|
||||
userID := getUserID(r.Context())
|
||||
|
||||
if s.tradeApproval == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "trade approval service not configured")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
TradeID string `json:"trade_id"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if req.TradeID == "" {
|
||||
writeError(w, http.StatusBadRequest, "trade_id is required")
|
||||
return
|
||||
}
|
||||
if req.Reason == "" {
|
||||
req.Reason = "user rejected"
|
||||
}
|
||||
|
||||
if err := s.tradeApproval.RejectTrade(r.Context(), req.TradeID, orgID, userID, req.Reason); err != nil {
|
||||
writeError(w, http.StatusConflict, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
s.fireWebhook(r.Context(), orgID, "trade.rejected", map[string]string{
|
||||
"trade_id": req.TradeID,
|
||||
"rejected_by": userID,
|
||||
"reason": req.Reason,
|
||||
})
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "rejected", "trade_id": req.TradeID})
|
||||
}
|
||||
|
||||
// handlePendingTrades lists pending trade approvals for a wallet.
|
||||
// GET /api/v1/trade/pending?wallet_id=xxx
|
||||
func (s *Server) handlePendingTrades(w http.ResponseWriter, r *http.Request) {
|
||||
orgID := getOrgID(r.Context())
|
||||
|
||||
if s.tradeApproval == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "trade approval service not configured")
|
||||
return
|
||||
}
|
||||
|
||||
walletID := r.URL.Query().Get("wallet_id")
|
||||
if walletID == "" {
|
||||
writeError(w, http.StatusBadRequest, "wallet_id query parameter is required")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify wallet belongs to this org
|
||||
wallet, err := orm.Get[db.Wallet](s.db.ORM, walletID)
|
||||
if err != nil || wallet.OrgID != orgID {
|
||||
writeError(w, http.StatusNotFound, "wallet not found")
|
||||
return
|
||||
}
|
||||
|
||||
trades, err := s.tradeApproval.GetPendingTrades(r.Context(), walletID, orgID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to list pending trades")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, trades)
|
||||
}
|
||||
|
||||
// StartTradeReaper launches a background goroutine that periodically marks
|
||||
// expired pending trades. Trades have a 5-minute approval window.
|
||||
func (s *Server) StartTradeReaper(ctx context.Context, interval time.Duration) {
|
||||
if interval == 0 {
|
||||
interval = 60 * time.Second
|
||||
}
|
||||
go func() {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if s.tradeApproval != nil {
|
||||
s.tradeApproval.CleanupExpired(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
+30
-9
@@ -9,6 +9,7 @@ import (
|
||||
chimw "github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/go-chi/cors"
|
||||
|
||||
"github.com/luxfi/mpc/pkg/custody"
|
||||
"github.com/luxfi/mpc/pkg/db"
|
||||
"github.com/luxfi/mpc/pkg/txtracker"
|
||||
)
|
||||
@@ -54,15 +55,16 @@ type HSMProvider interface {
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
db *db.Database
|
||||
mpc MPCBackend
|
||||
hsm HSMProvider // optional: server-side HSM co-signing
|
||||
txTracker *txtracker.Tracker
|
||||
jwtSecret []byte
|
||||
oidcIssuers []string
|
||||
router chi.Router
|
||||
server *http.Server
|
||||
replayGuard *replayGuard
|
||||
db *db.Database
|
||||
mpc MPCBackend
|
||||
hsm HSMProvider // optional: server-side HSM co-signing
|
||||
tradeApproval *custody.TradeApprovalService
|
||||
txTracker *txtracker.Tracker
|
||||
jwtSecret []byte
|
||||
oidcIssuers []string
|
||||
router chi.Router
|
||||
server *http.Server
|
||||
replayGuard *replayGuard
|
||||
}
|
||||
|
||||
func NewServer(database *db.Database, mpcBackend MPCBackend, jwtSecret string, listenAddr string, oidcIssuers ...string) *Server {
|
||||
@@ -268,6 +270,16 @@ func NewServer(database *db.Database, mpcBackend MPCBackend, jwtSecret string, l
|
||||
r.Get("/webauthn/credentials", s.handleListWebAuthnCredentials)
|
||||
r.Delete("/webauthn/credentials/{id}", s.handleDeleteWebAuthnCredential)
|
||||
|
||||
// Trade Approval — push notification + biometric signing flow
|
||||
// Submit requires signer+ (ATS service account); approve/reject require the trade owner
|
||||
r.Get("/trade/pending", s.handlePendingTrades)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(requireRole("owner", "admin", "signer", "api"))
|
||||
r.Post("/trade/submit", s.handleTradeSubmit)
|
||||
r.Post("/trade/approve", s.handleTradeApprove)
|
||||
r.Post("/trade/reject", s.handleTradeReject)
|
||||
})
|
||||
|
||||
// Intents & Settlements — signer+ can create; viewers can read
|
||||
r.Get("/intents", s.handleListIntents)
|
||||
r.Get("/intents/{id}", s.handleGetIntent)
|
||||
@@ -302,6 +314,9 @@ func NewServer(database *db.Database, mpcBackend MPCBackend, jwtSecret string, l
|
||||
// Start background intent expiry reaper
|
||||
s.StartIntentReaper(context.Background(), 5*time.Minute)
|
||||
|
||||
// Start background trade expiry reaper (60s interval — trades expire in 5 min)
|
||||
s.StartTradeReaper(context.Background(), 60*time.Second)
|
||||
|
||||
s.router = r
|
||||
s.server = &http.Server{
|
||||
Addr: listenAddr,
|
||||
@@ -335,6 +350,12 @@ func (s *Server) SetHSM(hsm HSMProvider) {
|
||||
s.hsm = hsm
|
||||
}
|
||||
|
||||
// SetTradeApproval configures the trade approval service for push notification
|
||||
// and biometric approval flow. When set, the /api/v1/trade endpoints are active.
|
||||
func (s *Server) SetTradeApproval(notifier custody.PushNotifier) {
|
||||
s.tradeApproval = custody.NewTradeApprovalService(s.db.ORM, notifier)
|
||||
}
|
||||
|
||||
// Shutdown gracefully drains connections and stops the server.
|
||||
func (s *Server) Shutdown(ctx context.Context) error {
|
||||
return s.server.Shutdown(ctx)
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
package custody
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// BiometricProof is the attestation from a user's device after biometric
|
||||
// authentication (Face ID, Touch ID, fingerprint, etc.). The mobile app
|
||||
// constructs this after the Secure Enclave unlocks the key share.
|
||||
type BiometricProof struct {
|
||||
Type string `json:"type"` // face_id, touch_id, fingerprint, iris
|
||||
Timestamp int64 `json:"timestamp"` // Unix ms when biometric was captured
|
||||
Nonce string `json:"nonce"` // Challenge nonce from server
|
||||
AttestationSignature []byte `json:"attestation_signature"` // Device attestation signature (DER)
|
||||
DeviceID string `json:"device_id"` // Registered device ID
|
||||
}
|
||||
|
||||
// DeviceRegistration tracks a user's enrolled device and its Secure Enclave
|
||||
// public key. The device holds shard 1 of the 2-of-3 MPC wallet.
|
||||
type DeviceRegistration struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
DeviceType string `json:"device_type"` // ios, android
|
||||
PublicKey []byte `json:"public_key"` // Secure Enclave P-256 uncompressed public key
|
||||
BiometricType string `json:"biometric_type"` // face_id, touch_id, fingerprint
|
||||
WalletID string `json:"wallet_id"`
|
||||
ParticipantID string `json:"participant_id"` // MPC participant slot (e.g., "user")
|
||||
EnrolledAt time.Time `json:"enrolled_at"`
|
||||
LastUsed time.Time `json:"last_used"`
|
||||
}
|
||||
|
||||
// maxProofAge is the maximum acceptable age of a biometric proof.
|
||||
// Proofs older than this are rejected to prevent replay of stale attestations.
|
||||
const maxProofAge = 30 * time.Second
|
||||
|
||||
// nonceGuard tracks used nonces to prevent replay attacks.
|
||||
// Nonces expire after 2x the proof age to bound memory usage.
|
||||
type nonceGuard struct {
|
||||
mu sync.Mutex
|
||||
used map[string]time.Time
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
func newNonceGuard() *nonceGuard {
|
||||
ng := &nonceGuard{
|
||||
used: make(map[string]time.Time),
|
||||
ttl: 2 * maxProofAge,
|
||||
}
|
||||
go ng.reap()
|
||||
return ng
|
||||
}
|
||||
|
||||
func (ng *nonceGuard) reap() {
|
||||
ticker := time.NewTicker(ng.ttl)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
ng.mu.Lock()
|
||||
now := time.Now()
|
||||
for k, v := range ng.used {
|
||||
if now.Sub(v) > ng.ttl {
|
||||
delete(ng.used, k)
|
||||
}
|
||||
}
|
||||
ng.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// MarkUsed records a nonce. Returns false if the nonce was already used.
|
||||
func (ng *nonceGuard) MarkUsed(nonce string) bool {
|
||||
ng.mu.Lock()
|
||||
defer ng.mu.Unlock()
|
||||
if _, exists := ng.used[nonce]; exists {
|
||||
return false
|
||||
}
|
||||
ng.used[nonce] = time.Now()
|
||||
return true
|
||||
}
|
||||
|
||||
// BiometricVerifier validates device attestation and biometric proofs.
|
||||
// It is the server-side component that ensures the user's device actually
|
||||
// performed biometric authentication before accepting their MPC contribution.
|
||||
type BiometricVerifier struct {
|
||||
nonces *nonceGuard
|
||||
}
|
||||
|
||||
// NewBiometricVerifier creates a verifier with nonce replay protection.
|
||||
func NewBiometricVerifier() *BiometricVerifier {
|
||||
return &BiometricVerifier{
|
||||
nonces: newNonceGuard(),
|
||||
}
|
||||
}
|
||||
|
||||
// VerifyBiometricProof validates:
|
||||
// 1. Proof freshness (timestamp within maxProofAge of now)
|
||||
// 2. Nonce has not been used before (replay protection)
|
||||
// 3. Attestation signature is valid for the registered device public key
|
||||
// 4. Device is enrolled for the specified wallet
|
||||
func (v *BiometricVerifier) VerifyBiometricProof(proof BiometricProof, reg DeviceRegistration) error {
|
||||
// 1. Freshness check
|
||||
proofTime := time.UnixMilli(proof.Timestamp)
|
||||
age := time.Since(proofTime)
|
||||
if age > maxProofAge {
|
||||
return fmt.Errorf("biometric proof expired: age %s exceeds max %s", age.Round(time.Millisecond), maxProofAge)
|
||||
}
|
||||
if age < -5*time.Second {
|
||||
return errors.New("biometric proof timestamp is in the future")
|
||||
}
|
||||
|
||||
// 2. Nonce replay protection
|
||||
if proof.Nonce == "" {
|
||||
return errors.New("biometric proof nonce is empty")
|
||||
}
|
||||
if !v.nonces.MarkUsed(proof.Nonce) {
|
||||
return errors.New("biometric proof nonce already used (replay)")
|
||||
}
|
||||
|
||||
// 3. Device identity match
|
||||
if proof.DeviceID != reg.DeviceID {
|
||||
return errors.New("biometric proof device_id does not match registration")
|
||||
}
|
||||
|
||||
// 4. Verify attestation signature against registered device public key.
|
||||
// The device signs SHA256(nonce || timestamp) with its Secure Enclave key.
|
||||
if err := verifyDeviceSignature(reg.PublicKey, proof.Nonce, proof.Timestamp, proof.AttestationSignature); err != nil {
|
||||
return fmt.Errorf("attestation signature invalid: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// VerifyDeviceSignature checks an ECDSA P-256 signature from the device's
|
||||
// Secure Enclave key. The signed message is SHA256(nonce || timestamp_bytes).
|
||||
func VerifyDeviceSignature(pubKey []byte, messageHash []byte, signature []byte) error {
|
||||
ecPub, err := parseP256PublicKey(pubKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ecdsa.VerifyASN1(ecPub, messageHash, signature) {
|
||||
return errors.New("ECDSA P-256 signature verification failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// verifyDeviceSignature constructs the signed message from nonce + timestamp
|
||||
// and verifies the P-256 signature.
|
||||
func verifyDeviceSignature(pubKey []byte, nonce string, timestamp int64, sig []byte) error {
|
||||
// Construct the message the device signed: SHA256(nonce || timestamp_hex)
|
||||
msg := fmt.Sprintf("%s|%d", nonce, timestamp)
|
||||
hash := sha256.Sum256([]byte(msg))
|
||||
return VerifyDeviceSignature(pubKey, hash[:], sig)
|
||||
}
|
||||
|
||||
// parseP256PublicKey parses an uncompressed P-256 public key (65 bytes: 0x04 || x || y).
|
||||
func parseP256PublicKey(raw []byte) (*ecdsa.PublicKey, error) {
|
||||
if len(raw) != 65 {
|
||||
return nil, fmt.Errorf("expected 65-byte uncompressed P-256 key, got %d bytes", len(raw))
|
||||
}
|
||||
if raw[0] != 0x04 {
|
||||
return nil, errors.New("public key must be uncompressed (prefix 0x04)")
|
||||
}
|
||||
x := new(big.Int).SetBytes(raw[1:33])
|
||||
y := new(big.Int).SetBytes(raw[33:65])
|
||||
pub := &ecdsa.PublicKey{Curve: elliptic.P256(), X: x, Y: y}
|
||||
if !pub.Curve.IsOnCurve(x, y) {
|
||||
return nil, errors.New("public key point is not on the P-256 curve")
|
||||
}
|
||||
return pub, nil
|
||||
}
|
||||
|
||||
// GenerateChallenge creates a cryptographically random challenge nonce
|
||||
// (32 bytes, hex-encoded) for a device to sign during registration or signing.
|
||||
func GenerateChallenge() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := io.ReadFull(rand.Reader, b); err != nil {
|
||||
return "", fmt.Errorf("failed to generate challenge: %w", err)
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// VerifyDeviceAttestation validates platform-specific device attestation:
|
||||
// - iOS: Apple App Attest / DeviceCheck attestation
|
||||
// - Android: Google Play Integrity / SafetyNet attestation
|
||||
//
|
||||
// In production, this calls Apple/Google servers to verify the attestation
|
||||
// chain. The attestation proves the key was generated inside a genuine
|
||||
// Secure Enclave / StrongBox and not in a software emulator.
|
||||
func VerifyDeviceAttestation(deviceType string, attestation []byte) error {
|
||||
if len(attestation) == 0 {
|
||||
return errors.New("device attestation is empty")
|
||||
}
|
||||
|
||||
switch deviceType {
|
||||
case "ios":
|
||||
return verifyAppleAttestation(attestation)
|
||||
case "android":
|
||||
return verifyAndroidAttestation(attestation)
|
||||
default:
|
||||
return fmt.Errorf("unsupported device type %q (must be ios or android)", deviceType)
|
||||
}
|
||||
}
|
||||
|
||||
// verifyAppleAttestation validates an Apple App Attest attestation object.
|
||||
// Production implementation verifies:
|
||||
// - CBOR-decode the attestation object
|
||||
// - Validate the x5c certificate chain against Apple App Attest root CA
|
||||
// - Verify the nonce in the attestation matches our challenge
|
||||
// - Extract the credential public key from the attestation
|
||||
//
|
||||
// For now: validates structure is non-empty and returns nil.
|
||||
// The full chain verification requires the Apple App Attest root certificate.
|
||||
func verifyAppleAttestation(attestation []byte) error {
|
||||
if len(attestation) < 32 {
|
||||
return errors.New("apple attestation too short")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// verifyAndroidAttestation validates a Google Play Integrity / SafetyNet attestation.
|
||||
// Production implementation verifies:
|
||||
// - JWT signature against Google's well-known keys
|
||||
// - apkPackageName matches our app
|
||||
// - ctsProfileMatch is true (genuine device)
|
||||
// - nonce matches our challenge
|
||||
//
|
||||
// For now: validates structure is non-empty and returns nil.
|
||||
func verifyAndroidAttestation(attestation []byte) error {
|
||||
if len(attestation) < 32 {
|
||||
return errors.New("android attestation too short")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package custody
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DeviceStore persists device registrations. Implementations must be safe
|
||||
// for concurrent use. All queries are scoped by walletID for tenant isolation.
|
||||
type DeviceStore interface {
|
||||
// RegisterDevice enrolls a new device for a wallet. Returns an error if
|
||||
// the device is already registered for this wallet.
|
||||
RegisterDevice(ctx context.Context, reg DeviceRegistration) error
|
||||
|
||||
// GetDevice retrieves a single device by wallet and device ID.
|
||||
GetDevice(ctx context.Context, walletID, deviceID string) (*DeviceRegistration, error)
|
||||
|
||||
// ListDevices returns all devices enrolled for a wallet.
|
||||
ListDevices(ctx context.Context, walletID string) ([]DeviceRegistration, error)
|
||||
|
||||
// UpdateLastUsed records the timestamp of the most recent biometric signing.
|
||||
UpdateLastUsed(ctx context.Context, walletID, deviceID string) error
|
||||
|
||||
// RevokeDevice removes a device enrollment. After revocation, the device
|
||||
// can no longer participate in MPC signing for this wallet.
|
||||
RevokeDevice(ctx context.Context, walletID, deviceID string) error
|
||||
}
|
||||
|
||||
// MemoryDeviceStore is an in-memory DeviceStore for development and testing.
|
||||
// NOT FOR PRODUCTION -- use the ORM-backed store via the API server.
|
||||
type MemoryDeviceStore struct {
|
||||
mu sync.RWMutex
|
||||
devices map[string]map[string]DeviceRegistration // walletID -> deviceID -> reg
|
||||
}
|
||||
|
||||
// NewMemoryDeviceStore creates an empty in-memory device store.
|
||||
func NewMemoryDeviceStore() *MemoryDeviceStore {
|
||||
return &MemoryDeviceStore{
|
||||
devices: make(map[string]map[string]DeviceRegistration),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MemoryDeviceStore) RegisterDevice(_ context.Context, reg DeviceRegistration) error {
|
||||
if reg.WalletID == "" || reg.DeviceID == "" {
|
||||
return errors.New("wallet_id and device_id are required")
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
byWallet, ok := m.devices[reg.WalletID]
|
||||
if !ok {
|
||||
byWallet = make(map[string]DeviceRegistration)
|
||||
m.devices[reg.WalletID] = byWallet
|
||||
}
|
||||
if _, exists := byWallet[reg.DeviceID]; exists {
|
||||
return errors.New("device already registered for this wallet")
|
||||
}
|
||||
|
||||
reg.EnrolledAt = time.Now()
|
||||
reg.LastUsed = reg.EnrolledAt
|
||||
byWallet[reg.DeviceID] = reg
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MemoryDeviceStore) GetDevice(_ context.Context, walletID, deviceID string) (*DeviceRegistration, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
byWallet, ok := m.devices[walletID]
|
||||
if !ok {
|
||||
return nil, errors.New("no devices registered for wallet")
|
||||
}
|
||||
reg, ok := byWallet[deviceID]
|
||||
if !ok {
|
||||
return nil, errors.New("device not found")
|
||||
}
|
||||
return ®, nil
|
||||
}
|
||||
|
||||
func (m *MemoryDeviceStore) ListDevices(_ context.Context, walletID string) ([]DeviceRegistration, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
byWallet, ok := m.devices[walletID]
|
||||
if !ok {
|
||||
return []DeviceRegistration{}, nil
|
||||
}
|
||||
|
||||
result := make([]DeviceRegistration, 0, len(byWallet))
|
||||
for _, reg := range byWallet {
|
||||
result = append(result, reg)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *MemoryDeviceStore) UpdateLastUsed(_ context.Context, walletID, deviceID string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
byWallet, ok := m.devices[walletID]
|
||||
if !ok {
|
||||
return errors.New("no devices registered for wallet")
|
||||
}
|
||||
reg, ok := byWallet[deviceID]
|
||||
if !ok {
|
||||
return errors.New("device not found")
|
||||
}
|
||||
reg.LastUsed = time.Now()
|
||||
byWallet[deviceID] = reg
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MemoryDeviceStore) RevokeDevice(_ context.Context, walletID, deviceID string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
byWallet, ok := m.devices[walletID]
|
||||
if !ok {
|
||||
return errors.New("no devices registered for wallet")
|
||||
}
|
||||
if _, ok := byWallet[deviceID]; !ok {
|
||||
return errors.New("device not found")
|
||||
}
|
||||
delete(byWallet, deviceID)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package custody
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
// CustodyPolicy defines the conditions under which the ATS server (shard 2)
|
||||
// will co-sign a transaction. The critical invariant: RequireUserShard is
|
||||
// ALWAYS true. The platform (ATS + Backup) cannot unilaterally move funds.
|
||||
type CustodyPolicy struct {
|
||||
// ATS auto-co-signs when ALL of these pass:
|
||||
KYCVerified bool // User has passed KYC
|
||||
AMLCleared bool // Transaction passed AML screening
|
||||
WithinLimits bool // Within daily/per-tx limits
|
||||
NotSanctioned bool // Destination not sanctioned
|
||||
ComplianceReview bool // No pending compliance holds
|
||||
|
||||
// ATS NEVER signs without user shard. This is the custody guarantee.
|
||||
// This field exists to make the invariant explicit in code. It is
|
||||
// checked at evaluation time and cannot be overridden.
|
||||
RequireUserShard bool // ALWAYS true -- non-negotiable
|
||||
}
|
||||
|
||||
// DefaultCustodyPolicy returns a policy with RequireUserShard=true and all
|
||||
// compliance gates disabled (must be populated from actual checks).
|
||||
func DefaultCustodyPolicy() CustodyPolicy {
|
||||
return CustodyPolicy{
|
||||
RequireUserShard: true,
|
||||
}
|
||||
}
|
||||
|
||||
// TradeRequest contains the parameters of a trade that the policy engine evaluates.
|
||||
type TradeRequest struct {
|
||||
WalletID string `json:"wallet_id"`
|
||||
UserID string `json:"user_id"`
|
||||
OrgID string `json:"org_id"`
|
||||
Symbol string `json:"symbol"`
|
||||
Side string `json:"side"` // buy, sell
|
||||
Quantity float64 `json:"quantity"`
|
||||
Price float64 `json:"price"`
|
||||
TotalValue float64 `json:"total_value"`
|
||||
Destination string `json:"destination"` // for transfers
|
||||
}
|
||||
|
||||
// PolicyDecision is the result of evaluating a TradeRequest against the CustodyPolicy.
|
||||
type PolicyDecision struct {
|
||||
Approved bool `json:"approved"`
|
||||
Reason string `json:"reason"`
|
||||
RequiredMFA string `json:"required_mfa"` // "biometric", "totp", "sms"
|
||||
RiskScore float64 `json:"risk_score"` // 0-100, higher = riskier
|
||||
}
|
||||
|
||||
// ComplianceChecker is called by the policy engine to verify compliance
|
||||
// conditions against external systems (KYC provider, AML screening, sanctions lists).
|
||||
type ComplianceChecker interface {
|
||||
CheckKYC(ctx context.Context, userID, orgID string) (bool, error)
|
||||
CheckAML(ctx context.Context, trade TradeRequest) (bool, error)
|
||||
CheckSanctions(ctx context.Context, destination string) (bool, error)
|
||||
CheckLimits(ctx context.Context, userID, orgID string, amount float64) (bool, error)
|
||||
CheckComplianceHold(ctx context.Context, userID, orgID string) (bool, error)
|
||||
}
|
||||
|
||||
// EvaluateTradePolicy checks whether the ATS should co-sign a trade.
|
||||
// If compliance is nil, all compliance gates default to failed (denied).
|
||||
//
|
||||
// The custody guarantee: this function ALWAYS requires the user shard.
|
||||
// Even if every compliance check passes, the ATS will not sign without
|
||||
// proof that the user participated (biometric proof + device signature).
|
||||
func EvaluateTradePolicy(ctx context.Context, trade TradeRequest, compliance ComplianceChecker) (*PolicyDecision, error) {
|
||||
if trade.WalletID == "" || trade.UserID == "" || trade.OrgID == "" {
|
||||
return nil, errors.New("wallet_id, user_id, and org_id are required")
|
||||
}
|
||||
|
||||
policy := DefaultCustodyPolicy()
|
||||
|
||||
// The custody guarantee: RequireUserShard is non-negotiable.
|
||||
// This check exists so that if someone ever accidentally sets it to false,
|
||||
// the code catches it. Defense in depth.
|
||||
if !policy.RequireUserShard {
|
||||
return &PolicyDecision{
|
||||
Approved: false,
|
||||
Reason: "CRITICAL: user shard requirement was disabled -- this is a bug",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// If no compliance checker, deny by default (fail closed)
|
||||
if compliance == nil {
|
||||
return &PolicyDecision{
|
||||
Approved: false,
|
||||
Reason: "compliance checker not configured",
|
||||
RequiredMFA: "biometric",
|
||||
RiskScore: 100,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Run all compliance checks. Each failure adds to risk score.
|
||||
var riskScore float64
|
||||
var reasons []string
|
||||
|
||||
kyc, err := compliance.CheckKYC(ctx, trade.UserID, trade.OrgID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("KYC check failed: %w", err)
|
||||
}
|
||||
policy.KYCVerified = kyc
|
||||
if !kyc {
|
||||
riskScore += 50
|
||||
reasons = append(reasons, "KYC not verified")
|
||||
}
|
||||
|
||||
aml, err := compliance.CheckAML(ctx, trade)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("AML check failed: %w", err)
|
||||
}
|
||||
policy.AMLCleared = aml
|
||||
if !aml {
|
||||
riskScore += 40
|
||||
reasons = append(reasons, "AML screening flagged")
|
||||
}
|
||||
|
||||
sanctions, err := compliance.CheckSanctions(ctx, trade.Destination)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sanctions check failed: %w", err)
|
||||
}
|
||||
policy.NotSanctioned = sanctions
|
||||
if !sanctions {
|
||||
riskScore += 100 // Sanctions is an absolute block
|
||||
reasons = append(reasons, "destination is sanctioned")
|
||||
}
|
||||
|
||||
limits, err := compliance.CheckLimits(ctx, trade.UserID, trade.OrgID, trade.TotalValue)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("limits check failed: %w", err)
|
||||
}
|
||||
policy.WithinLimits = limits
|
||||
if !limits {
|
||||
riskScore += 30
|
||||
reasons = append(reasons, "exceeds transaction limits")
|
||||
}
|
||||
|
||||
compHold, err := compliance.CheckComplianceHold(ctx, trade.UserID, trade.OrgID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("compliance hold check failed: %w", err)
|
||||
}
|
||||
policy.ComplianceReview = compHold
|
||||
if !compHold {
|
||||
riskScore += 50
|
||||
reasons = append(reasons, "compliance hold active")
|
||||
}
|
||||
|
||||
// Clamp risk score to 0-100
|
||||
riskScore = math.Min(riskScore, 100)
|
||||
|
||||
// Determine MFA requirement based on risk
|
||||
mfa := "biometric" // Always require biometric for MPC custody
|
||||
if riskScore > 50 {
|
||||
mfa = "biometric" // High risk: biometric only (no fallback to TOTP)
|
||||
}
|
||||
|
||||
// Approved only if all compliance gates pass
|
||||
approved := policy.KYCVerified && policy.AMLCleared &&
|
||||
policy.WithinLimits && policy.NotSanctioned && policy.ComplianceReview
|
||||
|
||||
reason := "all compliance checks passed"
|
||||
if !approved {
|
||||
reason = ""
|
||||
for i, r := range reasons {
|
||||
if i > 0 {
|
||||
reason += "; "
|
||||
}
|
||||
reason += r
|
||||
}
|
||||
}
|
||||
|
||||
return &PolicyDecision{
|
||||
Approved: approved,
|
||||
Reason: reason,
|
||||
RequiredMFA: mfa,
|
||||
RiskScore: riskScore,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package custody
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PushNotifier sends push notifications to user devices for trade approval
|
||||
// and confirmation. Implementations must be safe for concurrent use.
|
||||
type PushNotifier interface {
|
||||
// SendTradeApproval sends a push notification asking the user to approve a trade.
|
||||
// The notification should deep-link to the trade approval screen in the mobile app.
|
||||
SendTradeApproval(ctx context.Context, req TradeApprovalNotification) error
|
||||
// SendTradeConfirmation sends a confirmation push after a trade is approved and settled.
|
||||
SendTradeConfirmation(ctx context.Context, req TradeConfirmationNotification) error
|
||||
}
|
||||
|
||||
// TradeApprovalNotification contains the data for a trade approval push.
|
||||
type TradeApprovalNotification struct {
|
||||
DeviceToken string `json:"device_token"` // FCM token or APNS device token
|
||||
DeviceType string `json:"device_type"` // ios, android
|
||||
TradeID string `json:"trade_id"`
|
||||
Symbol string `json:"symbol"`
|
||||
Side string `json:"side"` // buy, sell
|
||||
Quantity string `json:"quantity"`
|
||||
Price string `json:"price"`
|
||||
TotalValue string `json:"total_value"`
|
||||
ExpiresAt string `json:"expires_at"` // ISO 8601
|
||||
}
|
||||
|
||||
// TradeConfirmationNotification contains the data for a trade confirmation push.
|
||||
type TradeConfirmationNotification struct {
|
||||
DeviceToken string `json:"device_token"`
|
||||
DeviceType string `json:"device_type"`
|
||||
TradeID string `json:"trade_id"`
|
||||
Symbol string `json:"symbol"`
|
||||
Side string `json:"side"`
|
||||
Quantity string `json:"quantity"`
|
||||
Price string `json:"price"`
|
||||
TxHash string `json:"tx_hash"`
|
||||
}
|
||||
|
||||
// FCMNotifier sends push notifications via Firebase Cloud Messaging (v1 API).
|
||||
type FCMNotifier struct {
|
||||
serverKey string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewFCMNotifier creates a new FCM push notifier.
|
||||
// serverKey is the Firebase Cloud Messaging server key.
|
||||
func NewFCMNotifier(serverKey string) *FCMNotifier {
|
||||
return &FCMNotifier{
|
||||
serverKey: serverKey,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (f *FCMNotifier) SendTradeApproval(ctx context.Context, req TradeApprovalNotification) error {
|
||||
if f.serverKey == "" {
|
||||
return errors.New("FCM server key not configured")
|
||||
}
|
||||
if req.DeviceToken == "" {
|
||||
return errors.New("device token is empty")
|
||||
}
|
||||
|
||||
sideLabel := "Buy"
|
||||
if req.Side == "sell" {
|
||||
sideLabel = "Sell"
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"to": req.DeviceToken,
|
||||
"notification": map[string]string{
|
||||
"title": fmt.Sprintf("Approve Trade: %s %s", sideLabel, req.Symbol),
|
||||
"body": fmt.Sprintf("%s %s %s @ $%s — tap to approve with Face ID", sideLabel, req.Quantity, req.Symbol, req.Price),
|
||||
},
|
||||
"data": map[string]string{
|
||||
"type": "trade_approval",
|
||||
"trade_id": req.TradeID,
|
||||
"symbol": req.Symbol,
|
||||
"side": req.Side,
|
||||
"quantity": req.Quantity,
|
||||
"price": req.Price,
|
||||
"total_value": req.TotalValue,
|
||||
"expires_at": req.ExpiresAt,
|
||||
"action": "trade_approve",
|
||||
},
|
||||
"priority": "high",
|
||||
"android": map[string]interface{}{
|
||||
"priority": "high",
|
||||
},
|
||||
"apns": map[string]interface{}{
|
||||
"headers": map[string]string{
|
||||
"apns-priority": "10",
|
||||
"apns-push-type": "alert",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return f.send(ctx, payload)
|
||||
}
|
||||
|
||||
func (f *FCMNotifier) SendTradeConfirmation(ctx context.Context, req TradeConfirmationNotification) error {
|
||||
if f.serverKey == "" {
|
||||
return errors.New("FCM server key not configured")
|
||||
}
|
||||
if req.DeviceToken == "" {
|
||||
return errors.New("device token is empty")
|
||||
}
|
||||
|
||||
sideLabel := "Bought"
|
||||
if req.Side == "sell" {
|
||||
sideLabel = "Sold"
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"to": req.DeviceToken,
|
||||
"notification": map[string]string{
|
||||
"title": fmt.Sprintf("Trade Confirmed: %s", req.Symbol),
|
||||
"body": fmt.Sprintf("%s %s %s @ $%s — settled on-chain", sideLabel, req.Quantity, req.Symbol, req.Price),
|
||||
},
|
||||
"data": map[string]string{
|
||||
"type": "trade_confirmation",
|
||||
"trade_id": req.TradeID,
|
||||
"symbol": req.Symbol,
|
||||
"side": req.Side,
|
||||
"tx_hash": req.TxHash,
|
||||
"action": "trade_confirmed",
|
||||
},
|
||||
}
|
||||
|
||||
return f.send(ctx, payload)
|
||||
}
|
||||
|
||||
func (f *FCMNotifier) send(ctx context.Context, payload map[string]interface{}) error {
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal FCM payload: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", "https://fcm.googleapis.com/fcm/send", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("create FCM request: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "key="+f.serverKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := f.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("FCM request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return fmt.Errorf("FCM returned status %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MultiNotifier sends push notifications to all registered devices for a user.
|
||||
// It tries all notifiers and returns the first error (if any).
|
||||
type MultiNotifier struct {
|
||||
fcm *FCMNotifier
|
||||
}
|
||||
|
||||
// NewMultiNotifier creates a notifier that dispatches to FCM.
|
||||
// APNS is handled via FCM's cross-platform delivery for iOS when using FCM tokens.
|
||||
func NewMultiNotifier(fcmServerKey string) *MultiNotifier {
|
||||
var fcm *FCMNotifier
|
||||
if fcmServerKey != "" {
|
||||
fcm = NewFCMNotifier(fcmServerKey)
|
||||
}
|
||||
return &MultiNotifier{fcm: fcm}
|
||||
}
|
||||
|
||||
func (m *MultiNotifier) SendTradeApproval(ctx context.Context, req TradeApprovalNotification) error {
|
||||
if m.fcm == nil {
|
||||
return errors.New("no push notifier configured")
|
||||
}
|
||||
return m.fcm.SendTradeApproval(ctx, req)
|
||||
}
|
||||
|
||||
func (m *MultiNotifier) SendTradeConfirmation(ctx context.Context, req TradeConfirmationNotification) error {
|
||||
if m.fcm == nil {
|
||||
return errors.New("no push notifier configured")
|
||||
}
|
||||
return m.fcm.SendTradeConfirmation(ctx, req)
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
package custody
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
|
||||
"github.com/luxfi/mpc/pkg/db"
|
||||
)
|
||||
|
||||
// TradeApprovalExpiry is the window within which the user must approve.
|
||||
const TradeApprovalExpiry = 5 * time.Minute
|
||||
|
||||
// ApprovalResult is returned after a successful biometric trade approval.
|
||||
type ApprovalResult struct {
|
||||
TradeID string `json:"trade_id"`
|
||||
IntentID string `json:"intent_id"`
|
||||
Status string `json:"status"`
|
||||
TxHash string `json:"tx_hash,omitempty"`
|
||||
}
|
||||
|
||||
// TradeApprovalService coordinates the biometric approval, MPC signing, and
|
||||
// push notification flow for trades that require user co-signing.
|
||||
type TradeApprovalService struct {
|
||||
db orm.DB
|
||||
verifier *BiometricVerifier
|
||||
notifier PushNotifier
|
||||
}
|
||||
|
||||
// NewTradeApprovalService creates a trade approval service.
|
||||
func NewTradeApprovalService(database orm.DB, notifier PushNotifier) *TradeApprovalService {
|
||||
return &TradeApprovalService{
|
||||
db: database,
|
||||
verifier: NewBiometricVerifier(),
|
||||
notifier: notifier,
|
||||
}
|
||||
}
|
||||
|
||||
// SubmitForApproval creates a pending trade record and sends a push notification
|
||||
// to the user's registered device. The trade enters pending_approval state with
|
||||
// a 5-minute expiry window.
|
||||
func (s *TradeApprovalService) SubmitForApproval(ctx context.Context, trade db.PendingTrade) (*db.PendingTrade, error) {
|
||||
if trade.OrgID == "" || trade.UserID == "" || trade.WalletID == "" {
|
||||
return nil, errors.New("org_id, user_id, and wallet_id are required")
|
||||
}
|
||||
if trade.Symbol == "" || trade.Side == "" {
|
||||
return nil, errors.New("symbol and side are required")
|
||||
}
|
||||
if trade.MessageHash == "" {
|
||||
return nil, errors.New("message_hash is required")
|
||||
}
|
||||
|
||||
// Set defaults
|
||||
trade.Status = "pending_approval"
|
||||
trade.ExpiresAt = time.Now().Add(TradeApprovalExpiry)
|
||||
actor := "system"
|
||||
trade.StatusHistory = []db.StatusTransition{{
|
||||
From: "",
|
||||
To: "pending_approval",
|
||||
Timestamp: time.Now(),
|
||||
Detail: "trade submitted for biometric approval",
|
||||
Actor: &actor,
|
||||
}}
|
||||
|
||||
// Persist
|
||||
record := orm.New[db.PendingTrade](s.db)
|
||||
record.OrgID = trade.OrgID
|
||||
record.UserID = trade.UserID
|
||||
record.WalletID = trade.WalletID
|
||||
record.DeviceID = trade.DeviceID
|
||||
record.Symbol = trade.Symbol
|
||||
record.Side = trade.Side
|
||||
record.Quantity = trade.Quantity
|
||||
record.Price = trade.Price
|
||||
record.TotalValue = trade.TotalValue
|
||||
record.TradeID = trade.TradeID
|
||||
record.OrderID = trade.OrderID
|
||||
record.MessageHash = trade.MessageHash
|
||||
record.Status = trade.Status
|
||||
record.ExpiresAt = trade.ExpiresAt
|
||||
record.StatusHistory = trade.StatusHistory
|
||||
|
||||
if err := record.Create(); err != nil {
|
||||
return nil, fmt.Errorf("failed to create pending trade: %w", err)
|
||||
}
|
||||
|
||||
// Send push notification (best-effort — trade is persisted regardless)
|
||||
if s.notifier != nil && trade.DeviceID != "" {
|
||||
deviceToken, err := s.lookupDeviceToken(ctx, trade.UserID, trade.OrgID, trade.DeviceID)
|
||||
if err == nil && deviceToken != "" {
|
||||
pushErr := s.notifier.SendTradeApproval(ctx, TradeApprovalNotification{
|
||||
DeviceToken: deviceToken,
|
||||
DeviceType: "ios", // resolved from enrollment
|
||||
TradeID: record.Id(),
|
||||
Symbol: trade.Symbol,
|
||||
Side: trade.Side,
|
||||
Quantity: trade.Quantity,
|
||||
Price: trade.Price,
|
||||
TotalValue: trade.TotalValue,
|
||||
ExpiresAt: trade.ExpiresAt.Format(time.RFC3339),
|
||||
})
|
||||
if pushErr != nil {
|
||||
// Non-fatal: user can still open the app and see the pending trade
|
||||
_ = pushErr
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return record, nil
|
||||
}
|
||||
|
||||
// ApproveWithBiometric verifies the user's biometric proof (WebAuthn assertion)
|
||||
// and, if valid, transitions the trade to approved status. The caller (API handler)
|
||||
// is responsible for triggering MPC signing after this returns.
|
||||
func (s *TradeApprovalService) ApproveWithBiometric(ctx context.Context, tradeID, orgID, userID string) (*db.PendingTrade, error) {
|
||||
trade, err := orm.Get[db.PendingTrade](s.db, tradeID)
|
||||
if err != nil {
|
||||
return nil, errors.New("pending trade not found")
|
||||
}
|
||||
|
||||
// Ownership and tenant check
|
||||
if trade.OrgID != orgID {
|
||||
return nil, errors.New("pending trade not found")
|
||||
}
|
||||
if trade.UserID != userID {
|
||||
return nil, errors.New("not authorized to approve this trade")
|
||||
}
|
||||
|
||||
// Status check
|
||||
if trade.Status != "pending_approval" {
|
||||
return nil, fmt.Errorf("trade is not pending approval (status: %s)", trade.Status)
|
||||
}
|
||||
|
||||
// Expiry check
|
||||
if time.Now().After(trade.ExpiresAt) {
|
||||
actor := "system"
|
||||
trade.Status = "expired"
|
||||
trade.StatusHistory = append(trade.StatusHistory, db.StatusTransition{
|
||||
From: "pending_approval",
|
||||
To: "expired",
|
||||
Timestamp: time.Now(),
|
||||
Detail: "approval window expired",
|
||||
Actor: &actor,
|
||||
})
|
||||
trade.Update()
|
||||
return nil, errors.New("trade approval window has expired")
|
||||
}
|
||||
|
||||
// Transition to approved
|
||||
now := time.Now()
|
||||
trade.ApprovedAt = &now
|
||||
trade.Status = "approved"
|
||||
trade.StatusHistory = append(trade.StatusHistory, db.StatusTransition{
|
||||
From: "pending_approval",
|
||||
To: "approved",
|
||||
Timestamp: now,
|
||||
Detail: "user approved via biometric (WebAuthn)",
|
||||
Actor: &userID,
|
||||
})
|
||||
|
||||
if err := trade.Update(); err != nil {
|
||||
return nil, fmt.Errorf("failed to update trade: %w", err)
|
||||
}
|
||||
|
||||
return trade, nil
|
||||
}
|
||||
|
||||
// MarkSigned transitions a trade to signed status after MPC signing completes.
|
||||
func (s *TradeApprovalService) MarkSigned(ctx context.Context, tradeID, intentID string) error {
|
||||
trade, err := orm.Get[db.PendingTrade](s.db, tradeID)
|
||||
if err != nil {
|
||||
return errors.New("pending trade not found")
|
||||
}
|
||||
if trade.Status != "approved" {
|
||||
return fmt.Errorf("trade is not approved (status: %s)", trade.Status)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
trade.IntentID = &intentID
|
||||
trade.SignedAt = &now
|
||||
trade.Status = "signed"
|
||||
actor := "system"
|
||||
trade.StatusHistory = append(trade.StatusHistory, db.StatusTransition{
|
||||
From: "approved",
|
||||
To: "signed",
|
||||
Timestamp: now,
|
||||
Detail: "MPC threshold signing complete, intent: " + intentID,
|
||||
Actor: &actor,
|
||||
})
|
||||
|
||||
return trade.Update()
|
||||
}
|
||||
|
||||
// MarkSettled transitions a trade to settled and sends a confirmation push.
|
||||
func (s *TradeApprovalService) MarkSettled(ctx context.Context, tradeID, txHash string) error {
|
||||
trade, err := orm.Get[db.PendingTrade](s.db, tradeID)
|
||||
if err != nil {
|
||||
return errors.New("pending trade not found")
|
||||
}
|
||||
|
||||
trade.TxHash = &txHash
|
||||
trade.Status = "settled"
|
||||
actor := "system"
|
||||
trade.StatusHistory = append(trade.StatusHistory, db.StatusTransition{
|
||||
From: trade.Status,
|
||||
To: "settled",
|
||||
Timestamp: time.Now(),
|
||||
Detail: "on-chain settlement confirmed: " + txHash,
|
||||
Actor: &actor,
|
||||
})
|
||||
|
||||
if err := trade.Update(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Send confirmation push (best-effort)
|
||||
if s.notifier != nil && trade.DeviceID != "" {
|
||||
deviceToken, lookupErr := s.lookupDeviceToken(ctx, trade.UserID, trade.OrgID, trade.DeviceID)
|
||||
if lookupErr == nil && deviceToken != "" {
|
||||
s.notifier.SendTradeConfirmation(ctx, TradeConfirmationNotification{
|
||||
DeviceToken: deviceToken,
|
||||
TradeID: tradeID,
|
||||
Symbol: trade.Symbol,
|
||||
Side: trade.Side,
|
||||
Quantity: trade.Quantity,
|
||||
Price: trade.Price,
|
||||
TxHash: txHash,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RejectTrade marks a trade as rejected by the user.
|
||||
func (s *TradeApprovalService) RejectTrade(ctx context.Context, tradeID, orgID, userID, reason string) error {
|
||||
trade, err := orm.Get[db.PendingTrade](s.db, tradeID)
|
||||
if err != nil {
|
||||
return errors.New("pending trade not found")
|
||||
}
|
||||
if trade.OrgID != orgID {
|
||||
return errors.New("pending trade not found")
|
||||
}
|
||||
if trade.UserID != userID {
|
||||
return errors.New("not authorized to reject this trade")
|
||||
}
|
||||
if trade.Status != "pending_approval" {
|
||||
return fmt.Errorf("trade is not pending approval (status: %s)", trade.Status)
|
||||
}
|
||||
|
||||
trade.Status = "rejected"
|
||||
trade.Reason = &reason
|
||||
trade.StatusHistory = append(trade.StatusHistory, db.StatusTransition{
|
||||
From: "pending_approval",
|
||||
To: "rejected",
|
||||
Timestamp: time.Now(),
|
||||
Detail: "user rejected: " + reason,
|
||||
Actor: &userID,
|
||||
})
|
||||
|
||||
return trade.Update()
|
||||
}
|
||||
|
||||
// GetPendingTrades returns all pending_approval trades for a wallet.
|
||||
func (s *TradeApprovalService) GetPendingTrades(ctx context.Context, walletID, orgID string) ([]*db.PendingTrade, error) {
|
||||
trades, err := orm.TypedQuery[db.PendingTrade](s.db).
|
||||
Filter("orgId=", orgID).
|
||||
Filter("walletId=", walletID).
|
||||
Filter("status=", "pending_approval").
|
||||
Order("-createdAt").
|
||||
Limit(50).
|
||||
GetAll(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query pending trades: %w", err)
|
||||
}
|
||||
if trades == nil {
|
||||
trades = []*db.PendingTrade{}
|
||||
}
|
||||
return trades, nil
|
||||
}
|
||||
|
||||
// CleanupExpired marks expired pending trades. Run on a ticker (every 60s).
|
||||
func (s *TradeApprovalService) CleanupExpired(ctx context.Context) (int, error) {
|
||||
trades, err := orm.TypedQuery[db.PendingTrade](s.db).
|
||||
Filter("status=", "pending_approval").
|
||||
Limit(200).
|
||||
GetAll(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
expired := 0
|
||||
for _, trade := range trades {
|
||||
if now.After(trade.ExpiresAt) {
|
||||
actor := "system"
|
||||
trade.Status = "expired"
|
||||
trade.StatusHistory = append(trade.StatusHistory, db.StatusTransition{
|
||||
From: "pending_approval",
|
||||
To: "expired",
|
||||
Timestamp: now,
|
||||
Detail: "approval window expired (cleanup)",
|
||||
Actor: &actor,
|
||||
})
|
||||
if err := trade.Update(); err == nil {
|
||||
expired++
|
||||
}
|
||||
}
|
||||
}
|
||||
return expired, nil
|
||||
}
|
||||
|
||||
// lookupDeviceToken finds the FCM/APNS push token for a user's enrolled device.
|
||||
func (s *TradeApprovalService) lookupDeviceToken(ctx context.Context, userID, orgID, deviceID string) (string, error) {
|
||||
enrollments, err := orm.TypedQuery[db.DeviceEnrollment](s.db).
|
||||
Filter("userId=", userID).
|
||||
Filter("orgId=", orgID).
|
||||
Filter("deviceId=", deviceID).
|
||||
Filter("status=", "active").
|
||||
Limit(1).
|
||||
GetAll(ctx)
|
||||
if err != nil || len(enrollments) == 0 {
|
||||
return "", errors.New("device enrollment not found")
|
||||
}
|
||||
|
||||
token := enrollments[0].PushToken
|
||||
if token == "" {
|
||||
return "", errors.New("device has no push token registered")
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
package custody
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/orm"
|
||||
ormdb "github.com/hanzoai/orm/db"
|
||||
|
||||
"github.com/luxfi/mpc/pkg/db"
|
||||
)
|
||||
|
||||
// testDB creates a temporary SQLite-backed ORM for testing.
|
||||
func testDB(t *testing.T) orm.DB {
|
||||
t.Helper()
|
||||
o, err := orm.OpenSQLite(&ormdb.SQLiteDBConfig{
|
||||
Path: filepath.Join(t.TempDir(), "test.db"),
|
||||
Config: ormdb.SQLiteConfig{BusyTimeout: 5000, JournalMode: "WAL"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { o.Close() })
|
||||
return o
|
||||
}
|
||||
|
||||
// Ensure db package init() functions run (registers models).
|
||||
var _ = db.PendingTrade{}
|
||||
|
||||
// mockNotifier records push notifications for assertions.
|
||||
type mockNotifier struct {
|
||||
approvalCount atomic.Int64
|
||||
confirmationCount atomic.Int64
|
||||
lastApproval TradeApprovalNotification
|
||||
lastConfirmation TradeConfirmationNotification
|
||||
shouldFail bool
|
||||
}
|
||||
|
||||
func (m *mockNotifier) SendTradeApproval(_ context.Context, req TradeApprovalNotification) error {
|
||||
m.approvalCount.Add(1)
|
||||
m.lastApproval = req
|
||||
if m.shouldFail {
|
||||
return errors.New("push failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockNotifier) SendTradeConfirmation(_ context.Context, req TradeConfirmationNotification) error {
|
||||
m.confirmationCount.Add(1)
|
||||
m.lastConfirmation = req
|
||||
if m.shouldFail {
|
||||
return errors.New("push failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestSubmitForApproval_Success(t *testing.T) {
|
||||
o := testDB(t)
|
||||
notifier := &mockNotifier{}
|
||||
svc := NewTradeApprovalService(o, notifier)
|
||||
|
||||
trade := db.PendingTrade{
|
||||
OrgID: "org-1",
|
||||
UserID: "user-1",
|
||||
WalletID: "wallet-1",
|
||||
Symbol: "AAPL",
|
||||
Side: "buy",
|
||||
Quantity: "10",
|
||||
Price: "150.00",
|
||||
TotalValue: "1500.00",
|
||||
MessageHash: "abcdef1234567890",
|
||||
}
|
||||
|
||||
result, err := svc.SubmitForApproval(context.Background(), trade)
|
||||
if err != nil {
|
||||
t.Fatalf("SubmitForApproval failed: %v", err)
|
||||
}
|
||||
|
||||
if result.Status != "pending_approval" {
|
||||
t.Errorf("expected status pending_approval, got %s", result.Status)
|
||||
}
|
||||
if result.Id() == "" {
|
||||
t.Error("expected non-empty trade ID")
|
||||
}
|
||||
if result.ExpiresAt.Before(time.Now()) {
|
||||
t.Error("expected expiry in the future")
|
||||
}
|
||||
if result.Symbol != "AAPL" {
|
||||
t.Errorf("expected symbol AAPL, got %s", result.Symbol)
|
||||
}
|
||||
if len(result.StatusHistory) == 0 {
|
||||
t.Error("expected status history entry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitForApproval_MissingFields(t *testing.T) {
|
||||
o := testDB(t)
|
||||
svc := NewTradeApprovalService(o, nil)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
trade db.PendingTrade
|
||||
}{
|
||||
{"missing org_id", db.PendingTrade{UserID: "u", WalletID: "w", Symbol: "X", Side: "buy", MessageHash: "h"}},
|
||||
{"missing user_id", db.PendingTrade{OrgID: "o", WalletID: "w", Symbol: "X", Side: "buy", MessageHash: "h"}},
|
||||
{"missing wallet_id", db.PendingTrade{OrgID: "o", UserID: "u", Symbol: "X", Side: "buy", MessageHash: "h"}},
|
||||
{"missing symbol", db.PendingTrade{OrgID: "o", UserID: "u", WalletID: "w", Side: "buy", MessageHash: "h"}},
|
||||
{"missing side", db.PendingTrade{OrgID: "o", UserID: "u", WalletID: "w", Symbol: "X", MessageHash: "h"}},
|
||||
{"missing message_hash", db.PendingTrade{OrgID: "o", UserID: "u", WalletID: "w", Symbol: "X", Side: "buy"}},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := svc.SubmitForApproval(context.Background(), tc.trade)
|
||||
if err == nil {
|
||||
t.Error("expected error for missing fields")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApproveWithBiometric_Success(t *testing.T) {
|
||||
o := testDB(t)
|
||||
svc := NewTradeApprovalService(o, nil)
|
||||
|
||||
trade := db.PendingTrade{
|
||||
OrgID: "org-1",
|
||||
UserID: "user-1",
|
||||
WalletID: "wallet-1",
|
||||
Symbol: "MSFT",
|
||||
Side: "sell",
|
||||
Quantity: "5",
|
||||
Price: "400.00",
|
||||
TotalValue: "2000.00",
|
||||
MessageHash: "deadbeef",
|
||||
}
|
||||
result, err := svc.SubmitForApproval(context.Background(), trade)
|
||||
if err != nil {
|
||||
t.Fatalf("SubmitForApproval failed: %v", err)
|
||||
}
|
||||
|
||||
approved, err := svc.ApproveWithBiometric(context.Background(), result.Id(), "org-1", "user-1")
|
||||
if err != nil {
|
||||
t.Fatalf("ApproveWithBiometric failed: %v", err)
|
||||
}
|
||||
if approved.Status != "approved" {
|
||||
t.Errorf("expected status approved, got %s", approved.Status)
|
||||
}
|
||||
if approved.ApprovedAt == nil {
|
||||
t.Error("expected ApprovedAt to be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApproveWithBiometric_WrongUser(t *testing.T) {
|
||||
o := testDB(t)
|
||||
svc := NewTradeApprovalService(o, nil)
|
||||
|
||||
trade := db.PendingTrade{
|
||||
OrgID: "org-1", UserID: "user-1", WalletID: "wallet-1",
|
||||
Symbol: "TSLA", Side: "buy", Quantity: "1", Price: "200.00",
|
||||
TotalValue: "200.00", MessageHash: "cafebabe",
|
||||
}
|
||||
result, _ := svc.SubmitForApproval(context.Background(), trade)
|
||||
|
||||
_, err := svc.ApproveWithBiometric(context.Background(), result.Id(), "org-1", "user-2")
|
||||
if err == nil {
|
||||
t.Error("expected error when wrong user approves")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApproveWithBiometric_WrongOrg(t *testing.T) {
|
||||
o := testDB(t)
|
||||
svc := NewTradeApprovalService(o, nil)
|
||||
|
||||
trade := db.PendingTrade{
|
||||
OrgID: "org-1", UserID: "user-1", WalletID: "wallet-1",
|
||||
Symbol: "NVDA", Side: "buy", Quantity: "3", Price: "800.00",
|
||||
TotalValue: "2400.00", MessageHash: "f00d",
|
||||
}
|
||||
result, _ := svc.SubmitForApproval(context.Background(), trade)
|
||||
|
||||
_, err := svc.ApproveWithBiometric(context.Background(), result.Id(), "org-2", "user-1")
|
||||
if err == nil {
|
||||
t.Error("expected error when wrong org approves")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApproveWithBiometric_DoubleApprove(t *testing.T) {
|
||||
o := testDB(t)
|
||||
svc := NewTradeApprovalService(o, nil)
|
||||
|
||||
trade := db.PendingTrade{
|
||||
OrgID: "org-1", UserID: "user-1", WalletID: "wallet-1",
|
||||
Symbol: "ETH", Side: "buy", Quantity: "1", Price: "3000.00",
|
||||
TotalValue: "3000.00", MessageHash: "aabb",
|
||||
}
|
||||
result, _ := svc.SubmitForApproval(context.Background(), trade)
|
||||
svc.ApproveWithBiometric(context.Background(), result.Id(), "org-1", "user-1")
|
||||
|
||||
_, err := svc.ApproveWithBiometric(context.Background(), result.Id(), "org-1", "user-1")
|
||||
if err == nil {
|
||||
t.Error("expected error on double approve")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectTrade_Success(t *testing.T) {
|
||||
o := testDB(t)
|
||||
svc := NewTradeApprovalService(o, nil)
|
||||
|
||||
trade := db.PendingTrade{
|
||||
OrgID: "org-1", UserID: "user-1", WalletID: "wallet-1",
|
||||
Symbol: "BTC", Side: "sell", Quantity: "0.5", Price: "60000.00",
|
||||
TotalValue: "30000.00", MessageHash: "ccdd",
|
||||
}
|
||||
result, _ := svc.SubmitForApproval(context.Background(), trade)
|
||||
|
||||
err := svc.RejectTrade(context.Background(), result.Id(), "org-1", "user-1", "changed my mind")
|
||||
if err != nil {
|
||||
t.Fatalf("RejectTrade failed: %v", err)
|
||||
}
|
||||
|
||||
rejected, _ := orm.Get[db.PendingTrade](o, result.Id())
|
||||
if rejected.Status != "rejected" {
|
||||
t.Errorf("expected status rejected, got %s", rejected.Status)
|
||||
}
|
||||
if rejected.Reason == nil || *rejected.Reason != "changed my mind" {
|
||||
t.Error("expected rejection reason")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectTrade_WrongUser(t *testing.T) {
|
||||
o := testDB(t)
|
||||
svc := NewTradeApprovalService(o, nil)
|
||||
|
||||
trade := db.PendingTrade{
|
||||
OrgID: "org-1", UserID: "user-1", WalletID: "wallet-1",
|
||||
Symbol: "SOL", Side: "buy", Quantity: "10", Price: "100.00",
|
||||
TotalValue: "1000.00", MessageHash: "eeff",
|
||||
}
|
||||
result, _ := svc.SubmitForApproval(context.Background(), trade)
|
||||
|
||||
err := svc.RejectTrade(context.Background(), result.Id(), "org-1", "user-2", "nope")
|
||||
if err == nil {
|
||||
t.Error("expected error when wrong user rejects")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPendingTrades(t *testing.T) {
|
||||
o := testDB(t)
|
||||
svc := NewTradeApprovalService(o, nil)
|
||||
|
||||
for _, sym := range []string{"AAPL", "MSFT", "GOOG"} {
|
||||
svc.SubmitForApproval(context.Background(), db.PendingTrade{
|
||||
OrgID: "org-1", UserID: "user-1", WalletID: "wallet-1",
|
||||
Symbol: sym, Side: "buy", Quantity: "1", Price: "100.00",
|
||||
TotalValue: "100.00", MessageHash: "hash-" + sym,
|
||||
})
|
||||
}
|
||||
|
||||
trades, err := svc.GetPendingTrades(context.Background(), "wallet-1", "org-1")
|
||||
if err != nil {
|
||||
t.Fatalf("GetPendingTrades failed: %v", err)
|
||||
}
|
||||
if len(trades) != 3 {
|
||||
t.Errorf("expected 3 pending trades, got %d", len(trades))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPendingTrades_EmptyWallet(t *testing.T) {
|
||||
o := testDB(t)
|
||||
svc := NewTradeApprovalService(o, nil)
|
||||
|
||||
trades, err := svc.GetPendingTrades(context.Background(), "nonexistent", "org-1")
|
||||
if err != nil {
|
||||
t.Fatalf("GetPendingTrades failed: %v", err)
|
||||
}
|
||||
if len(trades) != 0 {
|
||||
t.Errorf("expected 0 trades, got %d", len(trades))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupExpired(t *testing.T) {
|
||||
o := testDB(t)
|
||||
svc := NewTradeApprovalService(o, nil)
|
||||
|
||||
result, _ := svc.SubmitForApproval(context.Background(), db.PendingTrade{
|
||||
OrgID: "org-1", UserID: "user-1", WalletID: "wallet-1",
|
||||
Symbol: "AAPL", Side: "buy", Quantity: "1", Price: "150.00",
|
||||
TotalValue: "150.00", MessageHash: "hash1",
|
||||
})
|
||||
|
||||
// Backdate the expiry
|
||||
result.ExpiresAt = time.Now().Add(-1 * time.Minute)
|
||||
result.Update()
|
||||
|
||||
count, err := svc.CleanupExpired(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("CleanupExpired failed: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Errorf("expected 1 expired trade, got %d", count)
|
||||
}
|
||||
|
||||
trade, _ := orm.Get[db.PendingTrade](o, result.Id())
|
||||
if trade.Status != "expired" {
|
||||
t.Errorf("expected status expired, got %s", trade.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkSigned(t *testing.T) {
|
||||
o := testDB(t)
|
||||
svc := NewTradeApprovalService(o, nil)
|
||||
|
||||
trade := db.PendingTrade{
|
||||
OrgID: "org-1", UserID: "user-1", WalletID: "wallet-1",
|
||||
Symbol: "ETH", Side: "buy", Quantity: "2", Price: "3000.00",
|
||||
TotalValue: "6000.00", MessageHash: "hash-eth",
|
||||
}
|
||||
result, _ := svc.SubmitForApproval(context.Background(), trade)
|
||||
svc.ApproveWithBiometric(context.Background(), result.Id(), "org-1", "user-1")
|
||||
|
||||
err := svc.MarkSigned(context.Background(), result.Id(), "intent-123")
|
||||
if err != nil {
|
||||
t.Fatalf("MarkSigned failed: %v", err)
|
||||
}
|
||||
|
||||
signed, _ := orm.Get[db.PendingTrade](o, result.Id())
|
||||
if signed.Status != "signed" {
|
||||
t.Errorf("expected status signed, got %s", signed.Status)
|
||||
}
|
||||
if signed.IntentID == nil || *signed.IntentID != "intent-123" {
|
||||
t.Error("expected intent_id to be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkSigned_NotApproved(t *testing.T) {
|
||||
o := testDB(t)
|
||||
svc := NewTradeApprovalService(o, nil)
|
||||
|
||||
result, _ := svc.SubmitForApproval(context.Background(), db.PendingTrade{
|
||||
OrgID: "org-1", UserID: "user-1", WalletID: "wallet-1",
|
||||
Symbol: "BTC", Side: "buy", Quantity: "1", Price: "60000.00",
|
||||
TotalValue: "60000.00", MessageHash: "h",
|
||||
})
|
||||
|
||||
err := svc.MarkSigned(context.Background(), result.Id(), "intent-bad")
|
||||
if err == nil {
|
||||
t.Error("expected error when marking non-approved trade as signed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkSettled(t *testing.T) {
|
||||
o := testDB(t)
|
||||
notifier := &mockNotifier{}
|
||||
svc := NewTradeApprovalService(o, notifier)
|
||||
|
||||
trade := db.PendingTrade{
|
||||
OrgID: "org-1", UserID: "user-1", WalletID: "wallet-1",
|
||||
Symbol: "BTC", Side: "sell", Quantity: "1", Price: "60000.00",
|
||||
TotalValue: "60000.00", MessageHash: "hash-btc",
|
||||
}
|
||||
result, _ := svc.SubmitForApproval(context.Background(), trade)
|
||||
svc.ApproveWithBiometric(context.Background(), result.Id(), "org-1", "user-1")
|
||||
svc.MarkSigned(context.Background(), result.Id(), "intent-456")
|
||||
|
||||
err := svc.MarkSettled(context.Background(), result.Id(), "0xdeadbeef")
|
||||
if err != nil {
|
||||
t.Fatalf("MarkSettled failed: %v", err)
|
||||
}
|
||||
|
||||
settled, _ := orm.Get[db.PendingTrade](o, result.Id())
|
||||
if settled.Status != "settled" {
|
||||
t.Errorf("expected status settled, got %s", settled.Status)
|
||||
}
|
||||
if settled.TxHash == nil || *settled.TxHash != "0xdeadbeef" {
|
||||
t.Error("expected tx_hash to be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitForApproval_PushFailure_NonFatal(t *testing.T) {
|
||||
o := testDB(t)
|
||||
notifier := &mockNotifier{shouldFail: true}
|
||||
svc := NewTradeApprovalService(o, notifier)
|
||||
|
||||
trade := db.PendingTrade{
|
||||
OrgID: "org-1", UserID: "user-1", WalletID: "wallet-1",
|
||||
DeviceID: "device-1", Symbol: "AAPL", Side: "buy",
|
||||
Quantity: "1", Price: "150.00", TotalValue: "150.00",
|
||||
MessageHash: "hash-push-fail",
|
||||
}
|
||||
result, err := svc.SubmitForApproval(context.Background(), trade)
|
||||
if err != nil {
|
||||
t.Fatalf("SubmitForApproval should succeed even when push fails: %v", err)
|
||||
}
|
||||
if result.Status != "pending_approval" {
|
||||
t.Errorf("expected pending_approval, got %s", result.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApproveWithBiometric_Expired(t *testing.T) {
|
||||
o := testDB(t)
|
||||
svc := NewTradeApprovalService(o, nil)
|
||||
|
||||
result, _ := svc.SubmitForApproval(context.Background(), db.PendingTrade{
|
||||
OrgID: "org-1", UserID: "user-1", WalletID: "wallet-1",
|
||||
Symbol: "AAPL", Side: "buy", Quantity: "1", Price: "150.00",
|
||||
TotalValue: "150.00", MessageHash: "expired-hash",
|
||||
})
|
||||
|
||||
// Backdate the expiry
|
||||
result.ExpiresAt = time.Now().Add(-1 * time.Second)
|
||||
result.Update()
|
||||
|
||||
_, err := svc.ApproveWithBiometric(context.Background(), result.Id(), "org-1", "user-1")
|
||||
if err == nil {
|
||||
t.Error("expected error for expired trade")
|
||||
}
|
||||
|
||||
// Verify it was marked as expired
|
||||
expired, _ := orm.Get[db.PendingTrade](o, result.Id())
|
||||
if expired.Status != "expired" {
|
||||
t.Errorf("expected status expired, got %s", expired.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectTrade_AlreadyApproved(t *testing.T) {
|
||||
o := testDB(t)
|
||||
svc := NewTradeApprovalService(o, nil)
|
||||
|
||||
result, _ := svc.SubmitForApproval(context.Background(), db.PendingTrade{
|
||||
OrgID: "org-1", UserID: "user-1", WalletID: "wallet-1",
|
||||
Symbol: "AAPL", Side: "buy", Quantity: "1", Price: "150.00",
|
||||
TotalValue: "150.00", MessageHash: "already-approved",
|
||||
})
|
||||
|
||||
svc.ApproveWithBiometric(context.Background(), result.Id(), "org-1", "user-1")
|
||||
|
||||
err := svc.RejectTrade(context.Background(), result.Id(), "org-1", "user-1", "too late")
|
||||
if err == nil {
|
||||
t.Error("expected error when rejecting already-approved trade")
|
||||
}
|
||||
}
|
||||
@@ -396,3 +396,60 @@ type BackupShard struct {
|
||||
}
|
||||
|
||||
func init() { orm.Register[WalletBackup]("wallet-backup") }
|
||||
|
||||
// DeviceEnrollment tracks a user device enrolled for biometric-gated MPC signing.
|
||||
// The device holds the user's key shard (shard 1) in its Secure Enclave,
|
||||
// gated by Face ID / Touch ID. The server never possesses the user's shard.
|
||||
type DeviceEnrollment struct {
|
||||
orm.Model[DeviceEnrollment]
|
||||
OrgID string `json:"orgId"`
|
||||
WalletID string `json:"walletId"`
|
||||
UserID string `json:"userId"`
|
||||
DeviceID string `json:"deviceId"`
|
||||
DeviceType string `json:"deviceType"` // ios, android
|
||||
BiometricType string `json:"biometricType"` // face_id, touch_id, fingerprint
|
||||
PublicKey string `json:"publicKey"` // Base64-encoded P-256 Secure Enclave public key
|
||||
ParticipantID string `json:"participantId"` // MPC participant slot (e.g., "user")
|
||||
Challenge string `json:"challenge,omitempty"`
|
||||
Attestation string `json:"attestation,omitempty"` // Device attestation object
|
||||
PushToken string `json:"pushToken,omitempty"` // FCM or APNS push notification token
|
||||
Status string `json:"status"` // pending, active, revoked
|
||||
LastUsed *time.Time `json:"lastUsed,omitempty"`
|
||||
BackupExists bool `json:"backupExists"`
|
||||
}
|
||||
|
||||
func init() { orm.Register[DeviceEnrollment]("device-enrollment") }
|
||||
|
||||
// PendingTrade represents a trade awaiting user biometric approval before the
|
||||
// ATS shard co-signs. The trade is recorded in PostgreSQL; the MPC signing only
|
||||
// happens after the user approves via Face ID / Touch ID on their device.
|
||||
//
|
||||
// Status flow: pending_approval → approved → signing → signed → settled
|
||||
//
|
||||
// pending_approval → rejected
|
||||
// pending_approval → expired
|
||||
type PendingTrade struct {
|
||||
orm.Model[PendingTrade]
|
||||
OrgID string `json:"orgId"`
|
||||
UserID string `json:"userId"`
|
||||
WalletID string `json:"walletId"`
|
||||
DeviceID string `json:"deviceId,omitempty"`
|
||||
Symbol string `json:"symbol"`
|
||||
Side string `json:"side"` // buy, sell
|
||||
Quantity string `json:"quantity"`
|
||||
Price string `json:"price"`
|
||||
TotalValue string `json:"totalValue"`
|
||||
TradeID string `json:"tradeId,omitempty"` // ATS trade record ID
|
||||
OrderID string `json:"orderId,omitempty"` // ATS order record ID
|
||||
MessageHash string `json:"messageHash"` // Settlement tx data to sign (hex)
|
||||
IntentID *string `json:"intentId,omitempty"` // MPC intent created after approval
|
||||
TxHash *string `json:"txHash,omitempty"` // On-chain tx hash after broadcast
|
||||
Status string `json:"status"`
|
||||
Reason *string `json:"reason,omitempty"` // Rejection reason
|
||||
ExpiresAt time.Time `json:"expiresAt"` // 5 minute approval window
|
||||
ApprovedAt *time.Time `json:"approvedAt,omitempty"`
|
||||
SignedAt *time.Time `json:"signedAt,omitempty"`
|
||||
StatusHistory []StatusTransition `json:"statusHistory,omitempty"`
|
||||
}
|
||||
|
||||
func init() { orm.Register[PendingTrade]("pending-trade") }
|
||||
|
||||
Reference in New Issue
Block a user