Files
zeekay e5589e4a1a wallet: 9-tier wallet architecture as first-class concept
Add pkg/wallet implementing the 9-tier model (hot, warm, cold, gas,
bridge, contract_admin, validator, quarantine, disaster_recovery) with
canonical TierPolicy, in-memory Registry, domain-separation enforcement,
and per-wallet velocity/daily counters.

Domain separation rejects (cloud_provider, account) collisions and
HSM-provider collisions; cold + DR demand 3+ distinct cloud and HSM
vendors. Hot/warm/bridge/contract_admin demand 2+. Gas tier disables
the check (low value, allowlisted only).

Sign dispatch in pkg/api/handlers_wallet.go routes per tier:
quarantine rejects unconditionally; over-cap rejects with usage view;
airgapped tiers (cold, DR) return airgapped_queued; timelocked tiers
return timelocked with end-time; auto-approval tiers (hot, gas, bridge,
validator) commit counters and return auto_approved when amount is
under the large-amount escalation threshold; remainder return
approval_required with HumanApprovalsMin (or HumanApprovalsLarge above
threshold).

Routes mounted at /v1/wallet (no /api/ prefix per house rule).

Tests:
- pkg/wallet/tier_test.go: 16/16 pass
- e2e/tier_lifecycle_test.go: 9/9 pass
2025-12-28 15:54:10 -08:00

365 lines
13 KiB
Go

package wallet
import (
"context"
"errors"
"math/big"
"testing"
"time"
)
// All 9 tiers must have a canonical policy. If a tier is added to AllTiers
// without a policy entry — or vice versa — this test fails.
func TestAllTiersHaveCanonicalPolicies(t *testing.T) {
policies := StandardTierPolicies()
if len(policies) != 9 {
t.Fatalf("expected 9 tier policies, got %d", len(policies))
}
if len(AllTiers) != 9 {
t.Fatalf("expected 9 tiers in AllTiers, got %d", len(AllTiers))
}
for _, tier := range AllTiers {
p, ok := policies[tier]
if !ok {
t.Errorf("tier %s missing canonical policy", tier)
}
if p.Tier != tier {
t.Errorf("policy[%s].Tier=%s — drift", tier, p.Tier)
}
if p.MPCThreshold.T < 1 || p.MPCThreshold.N < p.MPCThreshold.T {
t.Errorf("tier %s has bogus threshold %d-of-%d", tier, p.MPCThreshold.T, p.MPCThreshold.N)
}
}
}
// Spot-check a handful of policy invariants from the executive doc.
func TestPolicyInvariants(t *testing.T) {
policies := StandardTierPolicies()
if !policies[TierHot].AllowAutoApproval {
t.Error("hot tier must allow auto-approval")
}
if !policies[TierHot].AllowlistRequired {
t.Error("hot tier must require allowlist")
}
if policies[TierCold].AllowAutoApproval {
t.Error("cold tier MUST NOT auto-approve")
}
if !policies[TierCold].AirgappedRequired {
t.Error("cold tier must be airgapped")
}
if !policies[TierDR].AirgappedRequired {
t.Error("DR tier must be airgapped")
}
if policies[TierQuarantine].AllowAutoApproval {
t.Error("quarantine MUST NOT auto-approve — ever")
}
if policies[TierQuarantine].DailyLimitWei != "0" {
t.Error("quarantine must have zero daily limit")
}
if policies[TierContractAdmin].TimelockDuration < 24*time.Hour {
t.Error("contract_admin must have at least 24h timelock")
}
if policies[TierGas].KYTRequired {
t.Error("gas tier should not require KYT (low value, allowlisted)")
}
}
// PolicyFor must panic on invalid tier — guarantees we don't fall through
// to a zero-valued policy by accident.
func TestPolicyForInvalidPanics(t *testing.T) {
defer func() {
if r := recover(); r == nil {
t.Fatal("expected panic for invalid tier")
}
}()
_ = PolicyFor(Tier("nonsense"))
}
// Domain separation: the canonical case described in the executive doc —
// 3 nodes spread across AWS + GCP + colo passes for a hot 2-of-3 wallet.
func TestDomainSeparation_HotSpread_OK(t *testing.T) {
r := NewInMemoryRegistry()
w := Wallet{
ID: "hot-eth-001",
OrgID: "org-1",
Tier: TierHot,
Chain: "eip155:1",
Address: "0xaaaa",
Threshold: ThresholdSpec{T: 2, N: 3},
Nodes: []NodeBinding{
{NodeID: "n0", CloudProvider: "aws", Account: "111", Region: "us-east-1", HSMProvider: "aws-cloudhsm"},
{NodeID: "n1", CloudProvider: "gcp", Account: "lux-mpc-prod", Region: "us-central1", HSMProvider: "gcp-cloud-hsm"},
{NodeID: "n2", CloudProvider: "colo", Account: "sfo3-rack-01", Region: "sfo3", HSMProvider: "thales-luna"},
},
}
if err := r.Create(context.Background(), w); err != nil {
t.Fatalf("expected create OK, got %v", err)
}
}
// 3-of-5 cold wallet across 3 cloud providers + 5 distinct HSM vendors —
// the gold-standard cold setup.
func TestDomainSeparation_Cold3of5_OK(t *testing.T) {
r := NewInMemoryRegistry()
w := Wallet{
ID: "cold-eth-treasury",
OrgID: "org-1",
Tier: TierCold,
Chain: "eip155:1",
Address: "0xbbbb",
Threshold: ThresholdSpec{T: 3, N: 5},
Nodes: []NodeBinding{
{NodeID: "n0", CloudProvider: "aws", Account: "111", Region: "us-east-1", HSMProvider: "aws-cloudhsm"},
{NodeID: "n1", CloudProvider: "gcp", Account: "lux-cold", Region: "us-central1", HSMProvider: "gcp-cloud-hsm"},
{NodeID: "n2", CloudProvider: "azure", Account: "lux-prod-az", Region: "eastus", HSMProvider: "azure-dedicated-hsm"},
{NodeID: "n3", CloudProvider: "colo", Account: "ny5-rack", Region: "ny5", HSMProvider: "thales-luna"},
{NodeID: "n4", CloudProvider: "onprem", Account: "vault-room", Region: "office", HSMProvider: "yubihsm"},
},
}
if err := r.Create(context.Background(), w); err != nil {
t.Fatalf("expected create OK, got %v", err)
}
}
// 3 nodes ALL in same AWS account: must fail for cold.
func TestDomainSeparation_ColdAllSameAccount_Fails(t *testing.T) {
r := NewInMemoryRegistry()
w := Wallet{
ID: "cold-bad-001",
OrgID: "org-1",
Tier: TierCold,
Chain: "eip155:1",
Address: "0xcccc",
Threshold: ThresholdSpec{T: 3, N: 5},
Nodes: []NodeBinding{
{NodeID: "n0", CloudProvider: "aws", Account: "111", Region: "us-east-1", HSMProvider: "aws-cloudhsm"},
{NodeID: "n1", CloudProvider: "aws", Account: "111", Region: "us-west-2", HSMProvider: "aws-cloudhsm"},
{NodeID: "n2", CloudProvider: "aws", Account: "111", Region: "eu-west-1", HSMProvider: "aws-cloudhsm"},
{NodeID: "n3", CloudProvider: "aws", Account: "111", Region: "ap-south-1", HSMProvider: "aws-cloudhsm"},
{NodeID: "n4", CloudProvider: "aws", Account: "111", Region: "ap-northeast-1", HSMProvider: "aws-cloudhsm"},
},
}
err := r.Create(context.Background(), w)
if err == nil {
t.Fatal("expected domain-separation failure")
}
if !errors.Is(err, ErrDomainCollision) {
t.Fatalf("expected ErrDomainCollision, got %v", err)
}
}
// 3 nodes, 3 distinct AWS accounts but same provider: must fail for cold
// (cold demands 3+ distinct cloud providers).
func TestDomainSeparation_ColdSameProviderDifferentAccounts_FailsCloudFloor(t *testing.T) {
r := NewInMemoryRegistry()
w := Wallet{
ID: "cold-2",
OrgID: "org-1",
Tier: TierCold,
Chain: "eip155:1",
Address: "0xdddd",
Threshold: ThresholdSpec{T: 3, N: 5},
Nodes: []NodeBinding{
{NodeID: "n0", CloudProvider: "aws", Account: "1", Region: "us-east-1", HSMProvider: "aws-cloudhsm"},
{NodeID: "n1", CloudProvider: "aws", Account: "2", Region: "us-east-2", HSMProvider: "thales-luna"},
{NodeID: "n2", CloudProvider: "aws", Account: "3", Region: "us-west-1", HSMProvider: "yubihsm"},
{NodeID: "n3", CloudProvider: "aws", Account: "4", Region: "us-west-2", HSMProvider: "zymbit"},
{NodeID: "n4", CloudProvider: "aws", Account: "5", Region: "eu-west-1", HSMProvider: "azure-dedicated-hsm"},
},
}
err := r.Create(context.Background(), w)
if err == nil {
t.Fatal("expected cloud-spread failure")
}
if !errors.Is(err, ErrInsufficientCloud) {
t.Fatalf("expected ErrInsufficientCloud, got %v", err)
}
}
// Hot tier 2-of-3 in the SAME AWS account fails (collision) — even though
// hot only demands 2 distinct clouds, the same-account rule is absolute.
func TestDomainSeparation_HotSameAccount_Fails(t *testing.T) {
r := NewInMemoryRegistry()
w := Wallet{
ID: "hot-bad",
OrgID: "org-1",
Tier: TierHot,
Chain: "eip155:1",
Address: "0xeeee",
Threshold: ThresholdSpec{T: 2, N: 3},
Nodes: []NodeBinding{
{NodeID: "n0", CloudProvider: "aws", Account: "111", Region: "us-east-1", HSMProvider: "aws-cloudhsm"},
{NodeID: "n1", CloudProvider: "aws", Account: "111", Region: "us-west-2", HSMProvider: "thales-luna"},
{NodeID: "n2", CloudProvider: "gcp", Account: "p", Region: "us-central1", HSMProvider: "yubihsm"},
},
}
err := r.Create(context.Background(), w)
if !errors.Is(err, ErrDomainCollision) {
t.Fatalf("expected ErrDomainCollision, got %v", err)
}
}
// Gas tier disables NodeDomainSeparation, so 3 nodes in the same account
// is allowed (gas wallets are throw-away anyway).
func TestDomainSeparation_GasCollocated_OK(t *testing.T) {
r := NewInMemoryRegistry()
w := Wallet{
ID: "gas-eth-001",
OrgID: "org-1",
Tier: TierGas,
Chain: "eip155:1",
Address: "0xffff",
Threshold: ThresholdSpec{T: 2, N: 3},
Nodes: []NodeBinding{
{NodeID: "n0", CloudProvider: "aws", Account: "111", Region: "us-east-1", HSMProvider: "aws-cloudhsm"},
{NodeID: "n1", CloudProvider: "aws", Account: "111", Region: "us-east-1", HSMProvider: "aws-cloudhsm"},
{NodeID: "n2", CloudProvider: "aws", Account: "111", Region: "us-east-1", HSMProvider: "aws-cloudhsm"},
},
}
if err := r.Create(context.Background(), w); err != nil {
t.Fatalf("gas wallet should accept collocated nodes, got %v", err)
}
}
// Threshold mismatch: declaring a "cold" tier with 2-of-3 must fail.
func TestCreateRejectsThresholdMismatch(t *testing.T) {
r := NewInMemoryRegistry()
w := Wallet{
ID: "cold-wrong-threshold",
OrgID: "org-1",
Tier: TierCold,
Chain: "eip155:1",
Address: "0x0",
Threshold: ThresholdSpec{T: 2, N: 3},
Nodes: []NodeBinding{
{NodeID: "n0", CloudProvider: "aws", Account: "1", Region: "us-east-1", HSMProvider: "aws-cloudhsm"},
{NodeID: "n1", CloudProvider: "gcp", Account: "p", Region: "us-central1", HSMProvider: "gcp-cloud-hsm"},
{NodeID: "n2", CloudProvider: "colo", Account: "sfo3", Region: "sfo3", HSMProvider: "thales-luna"},
},
}
err := r.Create(context.Background(), w)
if !errors.Is(err, ErrThresholdMismatch) {
t.Fatalf("expected ErrThresholdMismatch, got %v", err)
}
}
// Velocity counters: hot tier per-tx=10 ETH, velocity=20 ETH/hr.
// Charge 10 + 10 = 20 ETH inside the window — both succeed. A third 10
// ETH charge inside the same window pushes us to 30 ETH and must fail
// against the velocity cap. Rolling 2h forward (past the 1h window)
// drops the earlier hits and the third charge succeeds.
func TestVelocityWindowRollover(t *testing.T) {
store := NewUsageStore()
policy := PolicyFor(TierHot)
wei := func(eth int64) *big.Int {
v := big.NewInt(eth)
return v.Mul(v, big.NewInt(1_000_000_000_000_000_000))
}
t0 := time.Date(2026, 4, 27, 12, 0, 0, 0, time.UTC)
if err := store.CheckAndCharge("w1", wei(10), policy, t0); err != nil {
t.Fatalf("first 10 ETH charge should succeed, got %v", err)
}
if err := store.CheckAndCharge("w1", wei(10), policy, t0.Add(5*time.Minute)); err != nil {
t.Fatalf("second 10 ETH charge should succeed (cap exactly), got %v", err)
}
// Window total now 20 ETH (== cap). One more 10 ETH charge goes to 30.
err := store.CheckAndCharge("w1", wei(10), policy, t0.Add(10*time.Minute))
if !errors.Is(err, ErrVelocityExceeded) {
t.Fatalf("expected ErrVelocityExceeded, got %v", err)
}
// Roll past the window — earlier hits drop out, charge succeeds.
if err := store.CheckAndCharge("w1", wei(10), policy, t0.Add(2*time.Hour)); err != nil {
t.Fatalf("post-window charge should succeed, got %v", err)
}
}
// Per-tx limit: a 50 ETH single tx on hot must fail (per-tx cap = 10 ETH).
func TestPerTxLimit(t *testing.T) {
store := NewUsageStore()
policy := PolicyFor(TierHot)
wei := func(eth int64) *big.Int {
v := big.NewInt(eth)
return v.Mul(v, big.NewInt(1_000_000_000_000_000_000))
}
err := store.CheckAndCharge("w2", wei(50), policy, time.Now())
if !errors.Is(err, ErrPerTxLimitExceeded) {
t.Fatalf("expected ErrPerTxLimitExceeded, got %v", err)
}
}
// Daily cap: hot wallet limit 100 ETH/day. 10 charges of 10 ETH succeed,
// 11th fails with ErrDailyLimitExceeded.
func TestDailyLimitAndReset(t *testing.T) {
store := NewUsageStore()
policy := PolicyFor(TierHot)
wei := func(eth int64) *big.Int {
v := big.NewInt(eth)
return v.Mul(v, big.NewInt(1_000_000_000_000_000_000))
}
t0 := time.Date(2026, 4, 27, 0, 1, 0, 0, time.UTC)
for i := 0; i < 10; i++ {
// Spread across velocity windows so velocity doesn't bite first.
ts := t0.Add(time.Duration(i+1) * 2 * time.Hour)
if err := store.CheckAndCharge("d1", wei(10), policy, ts); err != nil {
t.Fatalf("charge %d/10 should succeed, got %v", i, err)
}
}
err := store.CheckAndCharge("d1", wei(10), policy, t0.Add(22*time.Hour))
if !errors.Is(err, ErrDailyLimitExceeded) {
t.Fatalf("expected ErrDailyLimitExceeded, got %v", err)
}
// Roll into next UTC day — daily resets, velocity window also clear.
tomorrow := t0.Add(48 * time.Hour)
if err := store.CheckAndCharge("d1", wei(10), policy, tomorrow); err != nil {
t.Fatalf("post-midnight charge should succeed, got %v", err)
}
}
// "inf" limit: cold tier per-tx is unbounded. A 1B-ETH charge passes the
// per-tx check (it would still fail other tier checks like timelock, but
// not at this layer).
func TestInfLimit(t *testing.T) {
store := NewUsageStore()
policy := PolicyFor(TierCold)
huge, _ := new(big.Int).SetString("1000000000000000000000000000", 10) // 1B ETH in wei
if err := store.CheckAndCharge("cold", huge, policy, time.Now()); err != nil {
t.Fatalf("cold per-tx should be unbounded, got %v", err)
}
}
// View returns parsed counters in JSON-friendly shape.
func TestUsageView(t *testing.T) {
store := NewUsageStore()
policy := PolicyFor(TierHot)
wei := big.NewInt(1).Mul(big.NewInt(1), big.NewInt(1_000_000_000_000_000_000)) // 1 ETH
now := time.Date(2026, 4, 27, 9, 0, 0, 0, time.UTC)
_ = store.CheckAndCharge("v1", wei, policy, now)
view, err := store.View("v1", policy, now.Add(time.Minute))
if err != nil {
t.Fatalf("View err: %v", err)
}
if view.WalletID != "v1" {
t.Errorf("walletID drift: %s", view.WalletID)
}
if view.HitsInWindow != 1 {
t.Errorf("expected 1 hit in window, got %d", view.HitsInWindow)
}
if view.DailyUsedWei != "1000000000000000000" {
t.Errorf("daily used wei wrong: %s", view.DailyUsedWei)
}
}
// Quarantine tier rejects every charge — DailyLimitWei="0".
func TestQuarantineRejectsAllCharges(t *testing.T) {
store := NewUsageStore()
policy := PolicyFor(TierQuarantine)
err := store.CheckAndCharge("q1", big.NewInt(1), policy, time.Now())
if err == nil {
t.Fatal("quarantine must reject any charge")
}
}