mirror of
https://github.com/luxfi/mpc.git
synced 2026-09-24 00:26:24 +00:00
CanonicalIntent + LocalVerifier + SignGate. The MPC node signs only if it independently verifies the policy bundle. Closes the bypassable single point of failure where each node trusted the coordinator. pkg/intent: CBOR-encoded intent, RFC 8949 Sec 4.2 deterministic encoding. Approvals + node attestations excluded from digest. pkg/policy/local_verifier: six-check gate (wallet, expiry, policy hash, approval threshold, allowlist+limits, risk). Fails CLOSED. Signed NodeAttestation for both approve and reject. pkg/risk: Provider interface. InternalAllowlist (always available), Chainalysis HTTP client + httptest coverage, TRM/Elliptic NOTIMPL with documented endpoint shapes. CompositeProvider AND/OR. pkg/mpc/sign_orchestrator: SignGate wraps verifier as protocol-agnostic gate. AuthorizeSign returns ErrSignBlocked wrapping the cause. AuthorizeAndDigest returns the digest the threshold scheme must sign over. e2e/policy_gate_test (tag policygate; pre-existing zapdb break) + pkg/mpc/sign_orchestrator_e2e_test (runs in normal test suite): 3-node simulation proves non-bypassable. 56 tests, 4 packages, all passing. go.sum: corrects luxfi/edwards25519 v0.1.0 /go.mod hash.
200 lines
5.1 KiB
Go
200 lines
5.1 KiB
Go
package intent
|
|
|
|
import (
|
|
"bytes"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// fixture returns a fully-populated CanonicalIntent for tests. All fields
|
|
// are deterministic so digests are stable across runs.
|
|
func fixture() *CanonicalIntent {
|
|
return &CanonicalIntent{
|
|
IntentVersion: IntentVersion,
|
|
SessionID: "sess-01HXYZ",
|
|
WalletID: "wallet-treasury-eth",
|
|
WalletTier: TierWarm,
|
|
Chain: "eip155:1",
|
|
Asset: "ETH",
|
|
From: "0xfrom",
|
|
To: "0xto",
|
|
Amount: "1000000000000000000", // 1 ETH wei
|
|
MaxFee: "21000000000000",
|
|
Nonce: "42",
|
|
CalldataHash: [32]byte{0x01, 0x02, 0x03},
|
|
HumanSummary: "send 1 ETH from treasury to vendor X",
|
|
PolicyID: "policy-warm-v3",
|
|
PolicyHash: [32]byte{0xaa, 0xbb, 0xcc},
|
|
RiskVerdictID: "risk-2026-04-27-001",
|
|
SimulationHash: [32]byte{0xde, 0xad, 0xbe, 0xef},
|
|
ExpiresAt: time.Date(2026, 4, 27, 12, 0, 0, 0, time.UTC),
|
|
}
|
|
}
|
|
|
|
func TestDigest_Stable(t *testing.T) {
|
|
ci := fixture()
|
|
d1, err := ci.Digest()
|
|
if err != nil {
|
|
t.Fatalf("digest: %v", err)
|
|
}
|
|
d2, err := ci.Digest()
|
|
if err != nil {
|
|
t.Fatalf("digest: %v", err)
|
|
}
|
|
if d1 != d2 {
|
|
t.Fatalf("digest not stable: %x != %x", d1, d2)
|
|
}
|
|
}
|
|
|
|
func TestDigest_DifferentFieldsDifferentDigest(t *testing.T) {
|
|
a := fixture()
|
|
b := fixture()
|
|
b.Amount = "999"
|
|
da, _ := a.Digest()
|
|
db, _ := b.Digest()
|
|
if da == db {
|
|
t.Fatalf("digest must change when amount changes")
|
|
}
|
|
}
|
|
|
|
// TestDigest_ApprovalsExcluded — approvals must NOT participate in the
|
|
// digest because approvals are signatures over the digest. Including them
|
|
// would make the digest depend on the signatures, which is circular.
|
|
func TestDigest_ApprovalsExcluded(t *testing.T) {
|
|
a := fixture()
|
|
b := fixture()
|
|
b.Approvals = []ApprovalSignature{{
|
|
SignerID: "exec-1",
|
|
PublicKey: bytes.Repeat([]byte{1}, 32),
|
|
Signature: bytes.Repeat([]byte{2}, 64),
|
|
SignedAt: time.Date(2026, 4, 27, 11, 0, 0, 0, time.UTC),
|
|
}}
|
|
da, _ := a.Digest()
|
|
db, _ := b.Digest()
|
|
if da != db {
|
|
t.Fatalf("digest must NOT change when approvals are added: %x != %x", da, db)
|
|
}
|
|
}
|
|
|
|
func TestDigest_NodeAttestationsExcluded(t *testing.T) {
|
|
a := fixture()
|
|
b := fixture()
|
|
b.NodeAttestations = []NodeAttestation{{
|
|
NodeID: "node0",
|
|
PublicKey: bytes.Repeat([]byte{3}, 32),
|
|
Verdict: "approve",
|
|
Signature: bytes.Repeat([]byte{4}, 64),
|
|
SignedAt: time.Date(2026, 4, 27, 11, 5, 0, 0, time.UTC),
|
|
}}
|
|
da, _ := a.Digest()
|
|
db, _ := b.Digest()
|
|
if da != db {
|
|
t.Fatalf("digest must NOT change when node attestations are added: %x != %x", da, db)
|
|
}
|
|
}
|
|
|
|
// TestMarshalRoundtrip — full intent (with approvals) survives CBOR
|
|
// encode/decode cycle.
|
|
func TestMarshalRoundtrip(t *testing.T) {
|
|
ci := fixture()
|
|
ci.Approvals = []ApprovalSignature{{
|
|
SignerID: "exec-cfo",
|
|
PublicKey: bytes.Repeat([]byte{0x10}, 32),
|
|
Signature: bytes.Repeat([]byte{0x20}, 64),
|
|
SignedAt: time.Date(2026, 4, 27, 11, 0, 0, 0, time.UTC),
|
|
}}
|
|
enc, err := ci.Marshal()
|
|
if err != nil {
|
|
t.Fatalf("marshal: %v", err)
|
|
}
|
|
got, err := Unmarshal(enc)
|
|
if err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
if got.SessionID != ci.SessionID || got.WalletID != ci.WalletID {
|
|
t.Fatalf("roundtrip mismatch")
|
|
}
|
|
if len(got.Approvals) != 1 || got.Approvals[0].SignerID != "exec-cfo" {
|
|
t.Fatalf("approvals roundtrip mismatch")
|
|
}
|
|
// Digests must match across the boundary.
|
|
d1, _ := ci.Digest()
|
|
d2, _ := got.Digest()
|
|
if d1 != d2 {
|
|
t.Fatalf("digest survived roundtrip mismatch: %x != %x", d1, d2)
|
|
}
|
|
}
|
|
|
|
// TestMarshal_DeterministicByteIdentity — two independently-built intents
|
|
// with the same body must produce byte-identical CBOR. This is what makes
|
|
// the digest reproducible across nodes.
|
|
func TestMarshal_DeterministicByteIdentity(t *testing.T) {
|
|
a := fixture()
|
|
b := fixture()
|
|
ea, err := a.Marshal()
|
|
if err != nil {
|
|
t.Fatalf("marshal a: %v", err)
|
|
}
|
|
eb, err := b.Marshal()
|
|
if err != nil {
|
|
t.Fatalf("marshal b: %v", err)
|
|
}
|
|
if !bytes.Equal(ea, eb) {
|
|
t.Fatalf("deterministic encoding failed: %x != %x", ea, eb)
|
|
}
|
|
}
|
|
|
|
func TestValidate_OK(t *testing.T) {
|
|
if err := fixture().Validate(); err != nil {
|
|
t.Fatalf("validate: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidate_RejectsBadVersion(t *testing.T) {
|
|
ci := fixture()
|
|
ci.IntentVersion = "0"
|
|
if err := ci.Validate(); err == nil {
|
|
t.Fatal("expected error for unsupported version")
|
|
}
|
|
}
|
|
|
|
func TestValidate_RejectsInvalidTier(t *testing.T) {
|
|
ci := fixture()
|
|
ci.WalletTier = WalletTier("nonsense")
|
|
if err := ci.Validate(); err == nil {
|
|
t.Fatal("expected error for invalid tier")
|
|
}
|
|
}
|
|
|
|
func TestValidate_RejectsZeroExpiry(t *testing.T) {
|
|
ci := fixture()
|
|
ci.ExpiresAt = time.Time{}
|
|
if err := ci.Validate(); err == nil {
|
|
t.Fatal("expected error for zero expiry")
|
|
}
|
|
}
|
|
|
|
func TestWalletTier_IsValid(t *testing.T) {
|
|
cases := []struct {
|
|
t WalletTier
|
|
ok bool
|
|
}{
|
|
{TierHot, true},
|
|
{TierWarm, true},
|
|
{TierCold, true},
|
|
{TierGas, true},
|
|
{TierBridge, true},
|
|
{TierContractAdmin, true},
|
|
{TierValidator, true},
|
|
{TierQuarantine, true},
|
|
{TierDR, true},
|
|
{WalletTier(""), false},
|
|
{WalletTier("foo"), false},
|
|
}
|
|
for _, c := range cases {
|
|
if c.t.IsValid() != c.ok {
|
|
t.Errorf("IsValid(%q) = %v, want %v", c.t, c.t.IsValid(), c.ok)
|
|
}
|
|
}
|
|
}
|