feat(mpc): R6 requireRegulator invariant test + T3 CodeQL/SBOM workflow fix

R6 — handlers_treasury_test.go covers the `requireRegulator=true` tier
invariant: when numeric quorum is reached WITHOUT the regulator on a tier
that requires them, handleTreasurySign must return 409 and revert the op
to pending_approval. Tests cover the reject path, the happy-path
finalization with regulator, the control case with requireRegulator=false,
and the create-time validator's refusal of regulator-required tiers on
wallets with no regulator shard.

T3 — ci.yml references `cmd/lux-mpc-cli` in three places (CodeQL build,
SBOM build, and post-CI build). That directory was renamed to `cmd/mpc` in
a9505db but the workflow was never updated, so CodeQL/SBOM have been
failing on a missing path. Fix all three references to `./cmd/mpc`.

Run: CGO_ENABLED=1 go test -tags json1 -run TestR6 -v ./pkg/api/
This commit is contained in:
2026-04-18 22:41:45 -07:00
parent 74ac84e908
commit 87c9867610
2 changed files with 217 additions and 4 deletions
+4 -4
View File
@@ -188,7 +188,7 @@ jobs:
- name: Build for CodeQL
run: |
CGO_ENABLED=0 go build -v ./cmd/mpcd
CGO_ENABLED=0 go build -v ./cmd/lux-mpc-cli
CGO_ENABLED=0 go build -v ./cmd/mpc
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
@@ -229,7 +229,7 @@ jobs:
- name: Build binaries
run: |
CGO_ENABLED=0 go build -o mpcd ./cmd/mpcd
CGO_ENABLED=0 go build -o lux-mpc-cli ./cmd/lux-mpc-cli
CGO_ENABLED=0 go build -o mpc ./cmd/mpc
- name: Generate SBOM with anchore/sbom-action (SPDX-JSON)
uses: anchore/sbom-action@v0
@@ -324,5 +324,5 @@ jobs:
- name: Build mpcd
run: CGO_ENABLED=0 GOWORK=off go build -mod=vendor -v ./cmd/mpcd
- name: Build lux-mpc-cli
run: CGO_ENABLED=0 GOWORK=off go build -mod=vendor -v ./cmd/lux-mpc-cli
- name: Build mpc CLI
run: CGO_ENABLED=0 GOWORK=off go build -mod=vendor -v ./cmd/mpc
+213
View File
@@ -0,0 +1,213 @@
// R6 — treasury tier `requireRegulator: true` invariant.
//
// When a tier is configured with requireRegulator=true, a numeric quorum
// that does NOT include the regulator signer must NOT finalize the op.
// The handler must return 409 and revert the op to pending_approval so
// the regulator's approval can still land.
//
// Spec: ~/work/liquidity/openapi/mpc.yaml → TreasuryTier.requireRegulator.
// Handler: handlers_treasury.go handleTreasurySign regulator invariant.
package api
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/hanzoai/orm"
"github.com/luxfi/mpc/pkg/db"
)
// seedRegulatorTreasury installs a treasury wallet + operation ready for the
// FINAL approver to land. ApprovedBy is pre-populated with `threshold-1`
// non-regulator signers so the next approval call tips numeric quorum.
// Returns (walletID, operationID, non-regulator signer-to-use-for-final-vote).
func seedRegulatorTreasury(
t *testing.T, s *Server, orgID string, threshold int, requireRegulator bool,
) (string, string, string) {
t.Helper()
tw := orm.New[db.TreasuryWallet](s.db.ORM)
tw.OrgID = orgID
tw.Name = "regulator-treasury"
tw.WalletID = "wallet-reg-1"
tw.Chain = "evm"
tw.Signers = []db.TreasurySigner{
{Role: db.TreasurySignerCompliance, KeyRef: "u-compliance"},
{Role: db.TreasurySignerTreasurer, KeyRef: "u-treasurer"},
{Role: db.TreasurySignerPlatformHSM, KeyRef: expectedPlatformHSMKeyRef(orgID)},
{Role: db.TreasurySignerBackupHSM, KeyRef: "u-backup-hsm"},
{Role: db.TreasurySignerRegulator, KeyRef: "u-regulator"},
}
tw.Tiers = []db.TreasuryTier{
{MaxValue: "inf", Threshold: threshold, RequireRegulator: requireRegulator},
}
tw.RegulatorShard = true
tw.Status = "active"
if err := tw.Create(); err != nil {
t.Fatalf("seed treasury wallet: %v", err)
}
pol := orm.New[db.Policy](s.db.ORM)
pol.OrgID = orgID
pol.Kind = "treasury"
pol.Name = "regulator-treasury-policy"
pol.Priority = 1000
pol.Action = "require_approval"
pol.RequiredApprovers = threshold
pol.Enabled = true
pol.Conditions = []byte(`{}`)
if err := pol.Create(); err != nil {
t.Fatalf("seed policy: %v", err)
}
tx := orm.New[db.Transaction](s.db.ORM)
tx.OrgID = orgID
wid := tw.WalletID
tx.WalletID = &wid
tx.TxType = "treasury_sign"
tx.Chain = "evm"
tx.Status = "pending_approval"
initiator := "u-initiator"
tx.InitiatedBy = &initiator
// Seed N-1 approvals from NON-regulator signers. The caller of this
// helper issues the threshold-tipping approval below; that call will
// tip numeric quorum without including the regulator.
tx.ApprovedBy = []string{"u-compliance", "u-treasurer"}
// If threshold > 3 we'd need to seed more; the tests below use threshold=3
// so ApprovedBy=2 is exactly threshold-1.
if threshold != 3 {
t.Fatalf("helper wired for threshold=3, got %d", threshold)
}
if err := tx.Create(); err != nil {
t.Fatalf("seed operation: %v", err)
}
// The final vote will come from u-backup-hsm — a non-regulator signer.
return wid, tx.Id(), "u-backup-hsm"
}
// postTreasurySign calls handleTreasurySign with the given principal and
// returns the HTTP response recorder.
func postTreasurySign(t *testing.T, s *Server, orgID, userID, opID string) *httptest.ResponseRecorder {
t.Helper()
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, userID)
reqCtx = context.WithValue(reqCtx, ctxRole, "signer")
req = req.WithContext(reqCtx)
rec := httptest.NewRecorder()
s.handleTreasurySign(rec, req)
return rec
}
// TestR6_RequireRegulator_QuorumWithoutRegulator_Rejected — the critical
// invariant. Tier has requireRegulator=true. The N-th approver (not the
// regulator) tips numeric quorum. Handler must return 409 and the op must
// revert to pending_approval so the regulator can still approve.
func TestR6_RequireRegulator_QuorumWithoutRegulator_Rejected(t *testing.T) {
s := newTreasuryServer(t)
orgID := "org-A"
_, opID, finalSigner := seedRegulatorTreasury(t, s, orgID, 3, true)
rec := postTreasurySign(t, s, orgID, finalSigner, opID)
if rec.Code != http.StatusConflict {
t.Fatalf("requireRegulator=true but quorum lacked regulator: expected 409, got %d body=%s",
rec.Code, rec.Body.String())
}
// The op must be back in pending_approval (revertQuorumWithoutRegulator).
fresh, err := orm.Get[db.Transaction](s.db.ORM, opID)
if err != nil {
t.Fatalf("reload op: %v", err)
}
if fresh.Status != "pending_approval" {
t.Fatalf("op status after regulator-missing quorum = %q, want pending_approval", fresh.Status)
}
// ApprovedBy must STILL contain the N-th approver so that when the
// regulator later approves, quorum is met correctly.
foundFinal := false
for _, id := range fresh.ApprovedBy {
if id == finalSigner {
foundFinal = true
}
}
if !foundFinal {
t.Fatalf("final signer %q missing from ApprovedBy %v", finalSigner, fresh.ApprovedBy)
}
}
// TestR6_RequireRegulator_QuorumWithRegulator_Finalizes — the happy path.
// When the regulator is one of the approvers reaching quorum, finalization
// succeeds and the handler returns 200.
func TestR6_RequireRegulator_QuorumWithRegulator_Finalizes(t *testing.T) {
s := newTreasuryServer(t)
orgID := "org-A"
_, opID, _ := seedRegulatorTreasury(t, s, orgID, 3, true)
// Swap one of the seeded non-regulator approvers for the regulator so
// the regulator is counted once quorum is reached — we need to do this
// by hand because seedRegulatorTreasury wires the non-reg happy path.
tx, err := orm.Get[db.Transaction](s.db.ORM, opID)
if err != nil {
t.Fatalf("reload op: %v", err)
}
tx.ApprovedBy = []string{"u-compliance", "u-regulator"}
if err := tx.Update(); err != nil {
t.Fatalf("update op: %v", err)
}
// Now u-treasurer tips quorum → regulator is already present.
rec := postTreasurySign(t, s, orgID, "u-treasurer", opID)
if rec.Code != http.StatusOK {
t.Fatalf("regulator present at quorum: expected 200, got %d body=%s",
rec.Code, rec.Body.String())
}
fresh, _ := orm.Get[db.Transaction](s.db.ORM, opID)
if fresh.Status != "approved" {
t.Fatalf("op status after regulator-included quorum = %q, want approved", fresh.Status)
}
}
// TestR6_RequireRegulator_FalseTier_NoRegulatorNeeded — control case.
// requireRegulator=false → numeric quorum alone is sufficient, regardless
// of who approved.
func TestR6_RequireRegulator_FalseTier_NoRegulatorNeeded(t *testing.T) {
s := newTreasuryServer(t)
orgID := "org-A"
_, opID, finalSigner := seedRegulatorTreasury(t, s, orgID, 3, false)
rec := postTreasurySign(t, s, orgID, finalSigner, opID)
if rec.Code != http.StatusOK {
t.Fatalf("requireRegulator=false, numeric quorum: expected 200, got %d body=%s",
rec.Code, rec.Body.String())
}
fresh, _ := orm.Get[db.Transaction](s.db.ORM, opID)
if fresh.Status != "approved" {
t.Fatalf("op status = %q, want approved", fresh.Status)
}
}
// TestR6_ValidateTier_RejectsRequireRegulatorWithoutShard — belt-and-braces
// check that the create-time validator already refuses a tier with
// requireRegulator=true when the wallet has no regulator shard. This is
// enforced in validateTreasuryTiers but re-asserting locks the contract.
func TestR6_ValidateTier_RejectsRequireRegulatorWithoutShard(t *testing.T) {
_, err := validateTreasuryTiers(
[]treasuryTierInput{
{MaxValue: "inf", Threshold: 3, RequireRegulator: true},
},
4, // 4 signers, no regulator shard
false,
)
if err == nil {
t.Fatal("validateTreasuryTiers: tier with requireRegulator=true must reject when regulatorShard=false")
}
}