Files
mpc/client/threshold.go
T

158 lines
6.0 KiB
Go

// Package client — canonical client interface for Lux MPC.
//
// import mpc "github.com/luxfi/mpc/client"
// var t mpc.Threshold = luxmpc.NewThreshold(cfg)
package client
import (
"context"
"errors"
)
// Typed errors. Callers branch via errors.Is.
var (
// ErrIdempotencyConflict is returned when a Sign call reuses an
// IdempotencyKey from a prior session whose canonical request hash
// differs from this one. Implementations MUST never return a
// cached signature when the request differs — that is a signing
// oracle.
ErrIdempotencyConflict = errors.New("mpc: idempotency conflict")
// ErrIdempotencyKeyRequired is returned when Sign is called with
// an empty IdempotencyKey.
ErrIdempotencyKeyRequired = errors.New("mpc: idempotency key required")
// ErrComplianceRejected is returned when the backend's compliance
// ruleset refuses the signature. The wrapped *ComplianceRejection
// names the gate.
ErrComplianceRejected = errors.New("mpc: compliance rejected")
// ErrSessionNotFound is returned by GetSession / Cancel for an
// unknown session id, OR for a session that does not belong to the
// caller's org (the implementation MUST NOT distinguish the two —
// disclosing existence is an enumeration oracle).
ErrSessionNotFound = errors.New("mpc: session not found")
)
// Threshold is the compliance-gated threshold-signature surface.
// Every custody mutation (mint, burn, transfer, settle) signs through
// Threshold.Sign — there is no direct path to the MPC backend.
//
// Identity: the request's UserID + OrgID MUST be derived from the
// validated bearer-auth Claims attached to ctx. Implementations MUST
// refuse a call whose request UserID/OrgID disagrees with ctx Claims.
type Threshold interface {
// Kind reports the backend identifier
// (ta-cggmp21 | lit-protocol | fireblocks-mpc | coinbase-custody).
Kind() string
// Sign requests a threshold signature.
//
// Idempotency contract:
// 1. IdempotencyKey is required. Empty → ErrIdempotencyKeyRequired.
// 2. The implementation MUST compute CanonicalHash(SignRequest)
// over every request field except IdempotencyKey itself, and
// bind the cached session to that hash.
// 3. A second call with the same IdempotencyKey and the same
// CanonicalHash returns the cached SignResult.
// 4. A second call with the same IdempotencyKey but a different
// CanonicalHash returns ErrIdempotencyConflict. NO signing.
//
// Compliance contract:
// The implementation MUST re-derive the on-chain payload hash
// from (Intent, WalletID, Asset, Amount, ChainID) and compare to
// the caller-supplied PayloadHash. Mismatch → reject with
// ErrPayloadMismatch. This prevents a caller from describing a
// "$10 settle" while signing a "$100M withdraw" hash.
Sign(ctx context.Context, req SignRequest) (*SignResult, error)
// GetSession returns the current state of a signature session
// belonging to the caller's org. Unknown ids and cross-org ids
// return ErrSessionNotFound indistinguishably.
GetSession(ctx context.Context, sessionID string) (*SignSession, error)
// Cancel cancels a pending session belonging to the caller's org.
//
// Scoping (MUST be enforced by the implementation):
// - Derive orgID from ctx Claims; the caller does not supply it.
// - SELECT-FOR-UPDATE the session row and verify session.org_id ==
// ctx.OrgID before transitioning. Mismatch returns
// ErrSessionNotFound — the same opaque response as a truly
// unknown id, so a cross-org enumeration cannot distinguish
// "exists, not yours" from "does not exist".
// - The session-state transition (pending → cancelled) MUST be a
// row-level CAS with the org predicate inside the WHERE so a
// concurrent legitimate cancel from the right org cannot lose
// to a spoofed cross-org call.
//
// Idempotent on already-cancelled sessions for the SAME org; cross-
// org cancellation returns ErrSessionNotFound even if the session
// is already cancelled (no oracle leak via repeat-cancel semantics).
Cancel(ctx context.Context, sessionID string) error
}
// SignRequest is the caller-side payload.
//
// UserID and OrgID are RESERVED for the implementation to populate
// from ctx Claims. Callers SHOULD leave them empty; if set, the
// implementation MUST verify they match ctx Claims and reject on
// mismatch.
type SignRequest struct {
// UserID + OrgID are server-populated from ctx Claims.
UserID, OrgID string
// Intent: settle_trade | swap_dex | mint | burn | withdraw |
// redeem | corporate_action.
Intent string
// WalletID is the provider_wallet_id from custody_wallets.
WalletID string
// Asset is the token symbol (BTC, USDL, ETH, ...).
Asset string
// Amount is the human-readable string.
Amount string
// ChainID is the EIP-155 chain id (or chain-specific identifier
// for non-EVM keys).
ChainID int
// PayloadHash is the 32-byte hex of the tx payload. The
// implementation MUST re-derive from the other request fields and
// reject mismatch.
PayloadHash string
// KeyType: secp256k1 | ed25519 | sr25519 | mldsa65.
KeyType string
// IdempotencyKey is required.
IdempotencyKey string
// TradeID is optional context for settle_trade.
TradeID string
}
// ComplianceRejection details which compliance gate refused a Sign call.
type ComplianceRejection struct {
// Reason: sanctions | velocity | blocklist | manual_review |
// key_disabled | tenant_disabled | payload_mismatch.
Reason string
// ApprovalID is the audit row id; appears in operator dashboards.
ApprovalID string
}
func (r *ComplianceRejection) Error() string { return "mpc: " + r.Reason }
// SignResult is the immediate response.
type SignResult struct {
SessionID string
Signature []byte
// Status: pending | signed.
Status string
ApprovalID string
}
// SignSession is the poll response.
type SignSession struct {
SessionID string
Status string // pending | signed | rejected | cancelled
Signature []byte
ApprovalID string
// Reason populated when Status=rejected. See ComplianceRejection.
Reason string
}