mirror of
https://github.com/luxfi/mpc.git
synced 2026-09-24 00:26:24 +00:00
* build(mpc): GOEXPERIMENT=jsonv2 alongside runtimesecret per SCALE_STANDARD
Per SCALE_STANDARD.md §2 (~/work/hanzo/hips/docs/SCALE_STANDARD.md) —
every Go production Dockerfile that emits JSON to a client builds with
GOEXPERIMENT=jsonv2. Verified on hanzoai/zip's json_bench_test.go:
- Edge POST roundtrip: -12% time, -23% allocs
- Marshal-only: -22% time
- Unmarshal-only: -19% time, -25% allocs
GOEXPERIMENT is comma-separated, so jsonv2 composes cleanly with the
pre-existing runtimesecret experiment used by the MPC data plane.
* ci: switch workflows to lux-build (luxfi-scoped ARC pool)
Cross-org runs-on dispatch failure: hanzo-build-linux-amd64 scale set is
registered to github.com/hanzoai and silently rejects luxfi/* jobs.
Switch to lux-build (githubConfigUrl=github.com/luxfi, max 20).
* test/zapauth: deterministic invalid-signature test (was flaky)
TestZapAuthInvalidSignature flipped one base64 char of the signature
segment. ECDSA accepts a wrong sig with probability ≈ 1/2^6 per
single-char flip (alphabet is 64), making the test ~1.5% flake rate
in CI.
Fix: replace the entire signature segment with all-A's. Still
decodable as base64url but cryptographically guaranteed-invalid
bytes — Verify rejection is now deterministic.
Verified: ran -count=10 ./pkg/zapauth/ — all pass.
* deps: bump accel/crypto/corona/threshold/fhe/lens/math to current tags
* api/db: decomplect — KeccakAddress on Wallet / TreasuryWallet / KeygenResult
Wave 5 of the workspace-wide eth* naming purge.
pkg/db/models.go — Wallet + TreasuryWallet:
- New canonical KeccakAddress *string field with json `keccakAddress`
- EthAddress kept with json `ethAddress` as `// Deprecated:` alias
- NormalizeAddresses() method on both structs mirrors the two fields
via the package-private mirrorAddrPtrPair helper
pkg/api/server.go — KeygenResult (backend response):
- New canonical KeccakAddress string field with json `keccak_address`
- EthAddress kept with json `eth_address` as `// Deprecated:` alias
- NormalizeAddresses() method mirrors both fields
pkg/api/handlers_wallets.go, handlers_treasury.go, handlers_bridge.go,
handlers_mpc.go — all sites that constructed wallet rows from MPC
backend results now:
1. Call result.NormalizeAddresses() on the backend response so both
KeccakAddress / EthAddress carry the same value before copy
2. Copy both fields onto the persisted Wallet / TreasuryWallet row
3. Call wal.NormalizeAddresses() to ensure DB row has both
4. Read from the canonical KeccakAddress at response-construction
sites (toWalletResponse, treasury response, bridge response)
The "ethereum"/"lux"/"xrp" labels in addresses map (handleGetWalletAddresses)
are per-chain DISPLAY labels keyed by chain name a client knows;
the values are the Keccak-derived 20-byte address. Map keys NOT
decomplected here — they're per-chain UI labels, not internal naming.
Effect on the wire:
- API consumers reading wallet.ethAddress see the same value as
wallet.keccakAddress
- New consumers can switch to keccakAddress today
- ORM persistence stores both fields (eventually consistent via
NormalizeAddresses calls at handler boundaries)
Tests: pkg/api unit tests pass (1.8s); pkg/db has no test files.
Deprecation alias retention means downstream KMS / dashboards
keep working at every step of the migration.
Per CLAUDE.md x.x.x+1.
* strip KeccakAddress + EthAddress dual-field bridge (no aliases)
Forward-only completion of the workspace strip-aliases cascade. The
canonical JSON field is evmAddress, the Go field is EVMAddress. No
NormalizeAddresses helpers, no mirrorAddrPtrPair, no deprecated
KeccakAddress, no deprecated EthAddress.
Touched:
pkg/api/server.go KeygenResult drops KeccakAddress +
EthAddress + NormalizeAddresses
pkg/db/models.go Wallet drops KeccakAddress + EthAddress
TreasuryWallet drops KeccakAddress +
EthAddress
Drops NormalizeAddresses + mirrorAddrPtrPair
pkg/api/handlers_wallets.go rip mirror calls; only EVMAddress
populated on Wallet rows
pkg/api/handlers_treasury.go rip mirror calls on TreasuryWallet
pkg/api/handlers_mpc.go rip NormalizeAddresses calls; rename
filter("ethAddress=") → ("evmAddress=")
pkg/api/handlers_bridge.go local var keccakAddress → evmAddr
pkg/api/handlers_smart_wallets.go wallet.EthAddress → wallet.EVMAddress
pkg/api/treasury_test.go mock KeygenResult EthAddress → EVMAddress
pkg/api/zap_kms_server_test.go same
cmd/mpcd/main.go pubKeyToEthAddress → pubKeyToEVMAddress,
\"eth_address\" → \"evm_address\",
local var ethAddr → evmAddr, KeygenResult
EthAddress → EVMAddress field
Deps: bump luxfi/metric v1.5.1 → v1.5.7 to pick up ExponentialBuckets
(via transitive luxfi/threshold dep).
The value IS \"EVM-runtime account address\" — that's the data model.
The Keccak256(secp256k1 pubkey) derivation is HOW.
564 lines
19 KiB
Go
564 lines
19 KiB
Go
package api
|
|
|
|
// Treasury governance tests — SQLite-backed. The Postgres-backed concurrency
|
|
// test lives in treasury_postgres_test.go; it exercises the same CAS
|
|
// invariants under READ COMMITTED + SELECT FOR UPDATE.
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/hanzoai/orm"
|
|
|
|
"github.com/luxfi/mpc/pkg/db"
|
|
)
|
|
|
|
// reqWithChiCtx attaches a chi RouteContext carrying the given url params
|
|
// onto the request so handlers that call urlParam(r, name) see the value
|
|
// when invoked outside the full router stack.
|
|
func reqWithChiCtx(r *http.Request, params map[string]string) *http.Request {
|
|
rctx := chi.NewRouteContext()
|
|
for k, v := range params {
|
|
rctx.URLParams.Add(k, v)
|
|
}
|
|
return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx))
|
|
}
|
|
|
|
// newTreasuryServer spins up an SQLite-backed Server with a stub MPC
|
|
// backend sufficient to exercise the treasury handlers without running a
|
|
// real MPC cluster.
|
|
func newTreasuryServer(t *testing.T) *Server {
|
|
t.Helper()
|
|
path := t.TempDir() + "/treasury.db"
|
|
d, err := db.New("sqlite://"+path, "")
|
|
if err != nil {
|
|
t.Fatalf("db.New: %v", err)
|
|
}
|
|
t.Cleanup(func() { d.Close() })
|
|
return &Server{
|
|
db: d,
|
|
mpc: &mockMPCBackend{keygenResult: &KeygenResult{WalletID: "wallet-t-1", EVMAddress: "0xabc"}},
|
|
}
|
|
}
|
|
|
|
// mkTreasuryCreateBody constructs a well-formed 3-of-5 create payload for
|
|
// the given org. Individual tests mutate before posting to assert rejection.
|
|
func mkTreasuryCreateBody(orgID string, regulatorShard bool) treasuryWalletCreateRequest {
|
|
signers := []treasurySignerInput{
|
|
{Role: db.TreasurySignerCompliance, KeyRef: "user-compliance", DisplayName: "Compliance"},
|
|
{Role: db.TreasurySignerTreasurer, KeyRef: "user-cfo", DisplayName: "Treasurer"},
|
|
{Role: db.TreasurySignerPlatformHSM, KeyRef: expectedPlatformHSMKeyRef(orgID)},
|
|
{Role: db.TreasurySignerBackupHSM, KeyRef: "providers/backup/hsm"},
|
|
}
|
|
if regulatorShard {
|
|
signers = append(signers, treasurySignerInput{
|
|
Role: db.TreasurySignerRegulator, KeyRef: "user-reg",
|
|
})
|
|
}
|
|
tiers := []treasuryTierInput{
|
|
{MaxValue: "100000", Threshold: 3},
|
|
{MaxValue: "inf", Threshold: 4},
|
|
}
|
|
if regulatorShard {
|
|
tiers[1].RequireRegulator = true
|
|
}
|
|
return treasuryWalletCreateRequest{
|
|
Name: "Ops Treasury",
|
|
Signers: signers,
|
|
Tiers: tiers,
|
|
RegulatorShard: regulatorShard,
|
|
}
|
|
}
|
|
|
|
func TestTreasury_CreateWallet_ValidatesShape(t *testing.T) {
|
|
s := newTreasuryServer(t)
|
|
ctx := context.Background()
|
|
orgID := "org-A"
|
|
userID := "u-1"
|
|
|
|
body := mkTreasuryCreateBody(orgID, false)
|
|
buf := &bytes.Buffer{}
|
|
_ = json.NewEncoder(buf).Encode(body)
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/mpc/treasury/wallets", buf)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
reqCtx := context.WithValue(req.Context(), ctxOrgID, orgID)
|
|
reqCtx = context.WithValue(reqCtx, ctxUserID, userID)
|
|
reqCtx = context.WithValue(reqCtx, ctxRole, "admin")
|
|
req = req.WithContext(reqCtx)
|
|
rec := httptest.NewRecorder()
|
|
s.handleCreateTreasuryWallet(rec, req)
|
|
if rec.Code != http.StatusCreated {
|
|
t.Fatalf("create treasury wallet: status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
var resp treasuryWalletResponse
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if len(resp.Signers) != 4 {
|
|
t.Fatalf("signers=%d want 4", len(resp.Signers))
|
|
}
|
|
if resp.RegulatorShard {
|
|
t.Fatalf("regulatorShard default must be false")
|
|
}
|
|
if resp.Tiers[0].Threshold != 3 || resp.Tiers[1].Threshold != 4 {
|
|
t.Fatalf("tiers=%v want [3,4]", resp.Tiers)
|
|
}
|
|
|
|
// Verify the tier policy was written with kind="treasury".
|
|
policies, _ := orm.TypedQuery[db.Policy](s.db.ORM).Filter("orgId=", orgID).GetAll(ctx)
|
|
found := false
|
|
for _, p := range policies {
|
|
if p.Kind == "treasury" {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("expected a policy with kind=treasury")
|
|
}
|
|
}
|
|
|
|
// TestTreasury_Create_Rejects3Signers asserts at least 4 signers required
|
|
// when regulatorShard=false (compliance, treasurer, platform_hsm,
|
|
// backup_hsm). Exactly 3 signers is insufficient.
|
|
func TestTreasury_Create_RejectsInsufficientSigners(t *testing.T) {
|
|
s := newTreasuryServer(t)
|
|
body := mkTreasuryCreateBody("org-A", false)
|
|
body.Signers = body.Signers[:2] // 2 signers
|
|
buf := &bytes.Buffer{}
|
|
_ = json.NewEncoder(buf).Encode(body)
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/mpc/treasury/wallets", buf)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
reqCtx := context.WithValue(req.Context(), ctxOrgID, "org-A")
|
|
reqCtx = context.WithValue(reqCtx, ctxUserID, "u")
|
|
reqCtx = context.WithValue(reqCtx, ctxRole, "admin")
|
|
req = req.WithContext(reqCtx)
|
|
rec := httptest.NewRecorder()
|
|
s.handleCreateTreasuryWallet(rec, req)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected 400 for <3 signers, got %d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestTreasury_Create_RejectsWrongOrgHSM — the core cross-org isolation
|
|
// invariant. Tenant A cannot create a treasury wallet that references
|
|
// tenant B's platform HSM key.
|
|
func TestTreasury_Create_RejectsWrongOrgHSM(t *testing.T) {
|
|
s := newTreasuryServer(t)
|
|
orgA := "liquidity"
|
|
// Try to reference mlc's HSM path while authenticated as liquidity.
|
|
body := mkTreasuryCreateBody(orgA, false)
|
|
for i := range body.Signers {
|
|
if body.Signers[i].Role == db.TreasurySignerPlatformHSM {
|
|
body.Signers[i].KeyRef = expectedPlatformHSMKeyRef("mlc")
|
|
}
|
|
}
|
|
buf := &bytes.Buffer{}
|
|
_ = json.NewEncoder(buf).Encode(body)
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/mpc/treasury/wallets", buf)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
reqCtx := context.WithValue(req.Context(), ctxOrgID, orgA)
|
|
reqCtx = context.WithValue(reqCtx, ctxUserID, "u-liquidity-admin")
|
|
reqCtx = context.WithValue(reqCtx, ctxRole, "admin")
|
|
req = req.WithContext(reqCtx)
|
|
rec := httptest.NewRecorder()
|
|
s.handleCreateTreasuryWallet(rec, req)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected 400 for wrong-org HSM, got %d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
body2 := map[string]string{}
|
|
_ = json.Unmarshal(rec.Body.Bytes(), &body2)
|
|
if body2["error"] == "" {
|
|
t.Fatalf("expected error body, got %s", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestTreasury_Create_RegulatorShardRequiresSigner asserts that
|
|
// regulatorShard=true demands a regulator signer in the set.
|
|
func TestTreasury_Create_RegulatorShardRequiresSigner(t *testing.T) {
|
|
s := newTreasuryServer(t)
|
|
body := mkTreasuryCreateBody("org-A", true)
|
|
// Drop the regulator.
|
|
out := []treasurySignerInput{}
|
|
for _, x := range body.Signers {
|
|
if x.Role != db.TreasurySignerRegulator {
|
|
out = append(out, x)
|
|
}
|
|
}
|
|
body.Signers = out
|
|
buf := &bytes.Buffer{}
|
|
_ = json.NewEncoder(buf).Encode(body)
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/mpc/treasury/wallets", buf)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
reqCtx := context.WithValue(req.Context(), ctxOrgID, "org-A")
|
|
reqCtx = context.WithValue(reqCtx, ctxUserID, "u")
|
|
reqCtx = context.WithValue(reqCtx, ctxRole, "admin")
|
|
req = req.WithContext(reqCtx)
|
|
rec := httptest.NewRecorder()
|
|
s.handleCreateTreasuryWallet(rec, req)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected 400 when regulatorShard=true and no regulator signer; got %d body=%s",
|
|
rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestTreasury_Create_RejectsLooseTiers asserts that a threshold below 3,
|
|
// a threshold above signer-count, or a missing "inf" final tier are
|
|
// rejected.
|
|
func TestTreasury_Create_RejectsLooseTiers(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
tiers []treasuryTierInput
|
|
}{
|
|
{"threshold<3", []treasuryTierInput{{MaxValue: "inf", Threshold: 2}}},
|
|
{"no inf tier", []treasuryTierInput{{MaxValue: "100", Threshold: 3}}},
|
|
{"threshold > signers", []treasuryTierInput{
|
|
{MaxValue: "100", Threshold: 3},
|
|
{MaxValue: "inf", Threshold: 10},
|
|
}},
|
|
{"non-monotonic", []treasuryTierInput{
|
|
{MaxValue: "100", Threshold: 4},
|
|
{MaxValue: "inf", Threshold: 3},
|
|
}},
|
|
{"invalid maxValue", []treasuryTierInput{
|
|
{MaxValue: "not-a-number", Threshold: 3},
|
|
{MaxValue: "inf", Threshold: 4},
|
|
}},
|
|
}
|
|
for _, c := range cases {
|
|
c := c
|
|
t.Run(c.name, func(t *testing.T) {
|
|
s := newTreasuryServer(t)
|
|
body := mkTreasuryCreateBody("org-A", false)
|
|
body.Tiers = c.tiers
|
|
buf := &bytes.Buffer{}
|
|
_ = json.NewEncoder(buf).Encode(body)
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/mpc/treasury/wallets", buf)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
reqCtx := context.WithValue(req.Context(), ctxOrgID, "org-A")
|
|
reqCtx = context.WithValue(reqCtx, ctxUserID, "u")
|
|
reqCtx = context.WithValue(reqCtx, ctxRole, "admin")
|
|
req = req.WithContext(reqCtx)
|
|
rec := httptest.NewRecorder()
|
|
s.handleCreateTreasuryWallet(rec, req)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("%s: expected 400, got %d body=%s",
|
|
c.name, rec.Code, rec.Body.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestTreasury_TierForOperation verifies the tier-selection algorithm picks
|
|
// the first covering tier.
|
|
func TestTreasury_TierForOperation(t *testing.T) {
|
|
s := &Server{}
|
|
tw := &db.TreasuryWallet{
|
|
Tiers: []db.TreasuryTier{
|
|
{MaxValue: "100000", Threshold: 3},
|
|
{MaxValue: "1000000", Threshold: 4},
|
|
{MaxValue: "inf", Threshold: 5, RequireRegulator: true},
|
|
},
|
|
}
|
|
cases := []struct {
|
|
amount string
|
|
want int
|
|
}{
|
|
{"0", 3},
|
|
{"50000", 3},
|
|
{"100000", 3},
|
|
{"100001", 4},
|
|
{"999999", 4},
|
|
{"1000001", 5},
|
|
{"99999999999999999999", 5}, // arbitrary big — inf tier
|
|
}
|
|
for _, c := range cases {
|
|
c := c
|
|
t.Run(c.amount, func(t *testing.T) {
|
|
amt := c.amount
|
|
tx := &db.Transaction{Amount: &amt}
|
|
got := s.tierForOperation(tw, tx)
|
|
if got.Threshold != c.want {
|
|
t.Errorf("amount=%s threshold=%d want %d", c.amount, got.Threshold, c.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestTreasury_Sign_CrossOrgRejected — tenant B's signer cannot approve
|
|
// tenant A's operation. The 404 path in handleTreasurySign must trigger
|
|
// via OrgID mismatch on the Transaction before any approval work happens.
|
|
func TestTreasury_Sign_CrossOrgRejected(t *testing.T) {
|
|
s := newTreasuryServer(t)
|
|
ctx := context.Background()
|
|
|
|
orgA := "org-A"
|
|
// Seed a treasury wallet + op under orgA.
|
|
tw := orm.New[db.TreasuryWallet](s.db.ORM)
|
|
tw.OrgID = orgA
|
|
tw.Name = "A-treasury"
|
|
tw.WalletID = "wallet-A"
|
|
tw.Chain = "evm"
|
|
tw.Signers = []db.TreasurySigner{
|
|
{Role: db.TreasurySignerCompliance, KeyRef: "u-A-comp"},
|
|
{Role: db.TreasurySignerTreasurer, KeyRef: "u-A-cfo"},
|
|
{Role: db.TreasurySignerPlatformHSM, KeyRef: expectedPlatformHSMKeyRef(orgA)},
|
|
{Role: db.TreasurySignerBackupHSM, KeyRef: "providers/backup/hsm"},
|
|
}
|
|
tw.Tiers = []db.TreasuryTier{{MaxValue: "inf", Threshold: 3}}
|
|
tw.Status = "active"
|
|
if err := tw.Create(); err != nil {
|
|
t.Fatalf("create tw: %v", err)
|
|
}
|
|
|
|
// Policy forcing threshold=3.
|
|
pol := orm.New[db.Policy](s.db.ORM)
|
|
pol.OrgID = orgA
|
|
pol.Kind = "treasury"
|
|
pol.Name = "t-pol"
|
|
pol.Priority = 1000
|
|
pol.Action = "require_approval"
|
|
pol.RequiredApprovers = 3
|
|
pol.Enabled = true
|
|
pol.Conditions = []byte(`{}`)
|
|
if err := pol.Create(); err != nil {
|
|
t.Fatalf("policy: %v", err)
|
|
}
|
|
|
|
// Operation under orgA.
|
|
tx := orm.New[db.Transaction](s.db.ORM)
|
|
tx.OrgID = orgA
|
|
wid := "wallet-A"
|
|
tx.WalletID = &wid
|
|
tx.TxType = "treasury_sign"
|
|
tx.Chain = "evm"
|
|
tx.Status = "pending_approval"
|
|
initiator := "u-A-initiator"
|
|
tx.InitiatedBy = &initiator
|
|
tx.ApprovedBy = []string{"seed"}
|
|
if err := tx.Create(); err != nil {
|
|
t.Fatalf("create op: %v", err)
|
|
}
|
|
opID := tx.Id()
|
|
|
|
// A tenant-B signer attempting to approve orgA's op. Handler must 404
|
|
// on the OrgID mismatch.
|
|
body := map[string]string{"operationId": opID}
|
|
buf := &bytes.Buffer{}
|
|
_ = json.NewEncoder(buf).Encode(body)
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/mpc/treasury/sign", buf)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
reqCtx := context.WithValue(req.Context(), ctxOrgID, "org-B")
|
|
reqCtx = context.WithValue(reqCtx, ctxUserID, "u-A-comp") // stolen principal
|
|
reqCtx = context.WithValue(reqCtx, ctxRole, "signer")
|
|
req = req.WithContext(reqCtx)
|
|
rec := httptest.NewRecorder()
|
|
s.handleTreasurySign(rec, req)
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("cross-org approve: got %d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
_ = ctx
|
|
}
|
|
|
|
// TestTreasury_Sign_NonSignerRejected asserts an authenticated user who is
|
|
// NOT part of the treasury signer set is 403'd.
|
|
func TestTreasury_Sign_NonSignerRejected(t *testing.T) {
|
|
s := newTreasuryServer(t)
|
|
|
|
orgID := "org-A"
|
|
tw := orm.New[db.TreasuryWallet](s.db.ORM)
|
|
tw.OrgID = orgID
|
|
tw.Name = "t"
|
|
tw.WalletID = "w-a"
|
|
tw.Chain = "evm"
|
|
tw.Signers = []db.TreasurySigner{
|
|
{Role: db.TreasurySignerCompliance, KeyRef: "u-comp"},
|
|
{Role: db.TreasurySignerTreasurer, KeyRef: "u-cfo"},
|
|
{Role: db.TreasurySignerPlatformHSM, KeyRef: expectedPlatformHSMKeyRef(orgID)},
|
|
{Role: db.TreasurySignerBackupHSM, KeyRef: "providers/backup/hsm"},
|
|
}
|
|
tw.Tiers = []db.TreasuryTier{{MaxValue: "inf", Threshold: 3}}
|
|
tw.Status = "active"
|
|
_ = tw.Create()
|
|
|
|
pol := orm.New[db.Policy](s.db.ORM)
|
|
pol.OrgID = orgID
|
|
pol.Kind = "treasury"
|
|
pol.Name = "t"
|
|
pol.Priority = 1000
|
|
pol.Action = "require_approval"
|
|
pol.RequiredApprovers = 3
|
|
pol.Enabled = true
|
|
pol.Conditions = []byte(`{}`)
|
|
_ = pol.Create()
|
|
|
|
tx := orm.New[db.Transaction](s.db.ORM)
|
|
tx.OrgID = orgID
|
|
wid := "w-a"
|
|
tx.WalletID = &wid
|
|
tx.TxType = "treasury_sign"
|
|
tx.Chain = "evm"
|
|
tx.Status = "pending_approval"
|
|
initiator := "u-init"
|
|
tx.InitiatedBy = &initiator
|
|
_ = tx.Create()
|
|
opID := tx.Id()
|
|
|
|
body := map[string]string{"operationId": opID}
|
|
buf := &bytes.Buffer{}
|
|
_ = json.NewEncoder(buf).Encode(body)
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/mpc/treasury/sign", buf)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
reqCtx := context.WithValue(req.Context(), ctxOrgID, orgID)
|
|
reqCtx = context.WithValue(reqCtx, ctxUserID, "u-random")
|
|
reqCtx = context.WithValue(reqCtx, ctxRole, "signer")
|
|
req = req.WithContext(reqCtx)
|
|
rec := httptest.NewRecorder()
|
|
s.handleTreasurySign(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("non-signer approve: got %d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestTreasury_RotateSigner_PlatformHSMScoped asserts a rotation that
|
|
// tries to reassign platform_hsm to another org's key path is rejected.
|
|
func TestTreasury_RotateSigner_PlatformHSMScoped(t *testing.T) {
|
|
s := newTreasuryServer(t)
|
|
|
|
orgID := "org-A"
|
|
tw := orm.New[db.TreasuryWallet](s.db.ORM)
|
|
tw.OrgID = orgID
|
|
tw.Name = "t"
|
|
tw.WalletID = "w-a"
|
|
tw.Chain = "evm"
|
|
tw.Signers = []db.TreasurySigner{
|
|
{Role: db.TreasurySignerCompliance, KeyRef: "u-comp"},
|
|
{Role: db.TreasurySignerTreasurer, KeyRef: "u-cfo"},
|
|
{Role: db.TreasurySignerPlatformHSM, KeyRef: expectedPlatformHSMKeyRef(orgID)},
|
|
{Role: db.TreasurySignerBackupHSM, KeyRef: "providers/backup/hsm"},
|
|
}
|
|
tw.Tiers = []db.TreasuryTier{{MaxValue: "inf", Threshold: 3}}
|
|
tw.Status = "active"
|
|
_ = tw.Create()
|
|
|
|
// Attempt: rotate platform_hsm to tenant-B's key path.
|
|
rbody := rotateSignerRequest{
|
|
Role: db.TreasurySignerPlatformHSM,
|
|
NewKeyRef: expectedPlatformHSMKeyRef("mlc"),
|
|
}
|
|
buf := &bytes.Buffer{}
|
|
_ = json.NewEncoder(buf).Encode(rbody)
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/mpc/treasury/wallets/"+tw.Id()+"/rotate-signer", buf)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
reqCtx := context.WithValue(req.Context(), ctxOrgID, orgID)
|
|
reqCtx = context.WithValue(reqCtx, ctxUserID, "u-admin")
|
|
reqCtx = context.WithValue(reqCtx, ctxRole, "admin")
|
|
req = req.WithContext(reqCtx)
|
|
rec := httptest.NewRecorder()
|
|
|
|
// Route via mux so {id} binds — set a chi RouteContext directly.
|
|
req = reqWithChiCtx(req, map[string]string{"id": tw.Id()})
|
|
s.handleRotateTreasurySigner(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("cross-org HSM rotation: got %d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestTreasury_TierEscalation_QuorumFromPolicy asserts that the wallet's
|
|
// tier policy drives `requiredApprovers`: a 100k op picks tier[0]=3, while
|
|
// a 500k op picks tier[1]=4.
|
|
func TestTreasury_TierEscalation_QuorumFromPolicy(t *testing.T) {
|
|
s := newTreasuryServer(t)
|
|
ctx := context.Background()
|
|
orgID := "org-A"
|
|
|
|
// Wallet with tiers: <=100k → 3, else 4.
|
|
tw := orm.New[db.TreasuryWallet](s.db.ORM)
|
|
tw.OrgID = orgID
|
|
tw.Name = "t"
|
|
tw.WalletID = "w-escalation"
|
|
tw.Chain = "evm"
|
|
tw.Signers = []db.TreasurySigner{
|
|
{Role: db.TreasurySignerCompliance, KeyRef: "u-comp"},
|
|
{Role: db.TreasurySignerTreasurer, KeyRef: "u-cfo"},
|
|
{Role: db.TreasurySignerPlatformHSM, KeyRef: expectedPlatformHSMKeyRef(orgID)},
|
|
{Role: db.TreasurySignerBackupHSM, KeyRef: "providers/backup/hsm"},
|
|
}
|
|
tw.Tiers = []db.TreasuryTier{
|
|
{MaxValue: "100000", Threshold: 3},
|
|
{MaxValue: "inf", Threshold: 4},
|
|
}
|
|
tw.Status = "active"
|
|
_ = tw.Create()
|
|
|
|
// Policy enforcing wallet-level approval quorum: require_approval with
|
|
// RequiredApprovers=3 as baseline. The tier selector above is exercised
|
|
// by tierForOperation.
|
|
pol := orm.New[db.Policy](s.db.ORM)
|
|
pol.OrgID = orgID
|
|
pol.Kind = "treasury"
|
|
pol.Name = "t"
|
|
pol.Priority = 1000
|
|
pol.Action = "require_approval"
|
|
pol.RequiredApprovers = 3
|
|
pol.Enabled = true
|
|
pol.Conditions = []byte(`{}`)
|
|
_ = pol.Create()
|
|
|
|
// Tier picker agrees with our expected thresholds.
|
|
low := "50000"
|
|
high := "200000"
|
|
txLow := &db.Transaction{Amount: &low}
|
|
txHigh := &db.Transaction{Amount: &high}
|
|
if got := s.tierForOperation(tw, txLow); got.Threshold != 3 {
|
|
t.Fatalf("low tier threshold=%d want 3", got.Threshold)
|
|
}
|
|
if got := s.tierForOperation(tw, txHigh); got.Threshold != 4 {
|
|
t.Fatalf("high tier threshold=%d want 4", got.Threshold)
|
|
}
|
|
_ = ctx
|
|
}
|
|
|
|
// TestTreasury_HSMKeyRefIsPerOrg asserts expectedPlatformHSMKeyRef generates
|
|
// distinct, disjoint paths per org and includes the full prefix/suffix.
|
|
func TestTreasury_HSMKeyRefIsPerOrg(t *testing.T) {
|
|
a := expectedPlatformHSMKeyRef("liquidity")
|
|
b := expectedPlatformHSMKeyRef("mlc")
|
|
c := expectedPlatformHSMKeyRef("vcc")
|
|
if a == b || b == c || a == c {
|
|
t.Fatalf("HSM keyRefs must be distinct across orgs: %s / %s / %s", a, b, c)
|
|
}
|
|
if a != "providers/liquidity/mpc-cosigner-key" {
|
|
t.Fatalf("liquidity path=%q", a)
|
|
}
|
|
if b != "providers/mlc/mpc-cosigner-key" {
|
|
t.Fatalf("mlc path=%q", b)
|
|
}
|
|
}
|
|
|
|
// TestTreasury_ApprovalsIncludeRegulator checks the regulator-lookup helper
|
|
// against an approver list.
|
|
func TestTreasury_ApprovalsIncludeRegulator(t *testing.T) {
|
|
tw := &db.TreasuryWallet{
|
|
Signers: []db.TreasurySigner{
|
|
{Role: db.TreasurySignerCompliance, KeyRef: "u-comp"},
|
|
{Role: db.TreasurySignerTreasurer, KeyRef: "u-cfo"},
|
|
{Role: db.TreasurySignerRegulator, KeyRef: "u-reg"},
|
|
},
|
|
}
|
|
if approvalsIncludeRegulator(tw, []string{"u-comp", "u-cfo"}) {
|
|
t.Fatal("regulator not present, must return false")
|
|
}
|
|
if !approvalsIncludeRegulator(tw, []string{"u-comp", "u-reg"}) {
|
|
t.Fatal("regulator present, must return true")
|
|
}
|
|
}
|
|
|
|
// ensure unused import keeps time in play for tests that later want it.
|
|
var _ = time.Now
|