mirror of
https://github.com/luxfi/fhe.git
synced 2026-07-27 07:24:44 +00:00
feat(policy): FHE-encoded transaction policy program
Adds policy/ subpackage that evaluates encrypted transaction policy clauses (amount limit, velocity cap, destination allowlist) against encrypted intent fields homomorphically. Result is a single encrypted boolean intended for threshold-decryption by the M-Chain MPC committee. - Bit-sliced via BitwiseEvaluator (Lt + chained Eq+OR + AND chain). - Deny-by-default: empty allowlist returns an error rather than silently allowing every transaction. - 4-bit test fixtures keep PN10QP27 noise budget within margin; full 64-bit production deployments use PN11QP54. - Tests: 12/12 passing in ~52s on M1 CPU.
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
// Copyright (c) 2026, Lux Industries Inc
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package policy implements an FHE-encoded transaction policy program.
|
||||
//
|
||||
// A PolicyProgram holds a set of encrypted policy clauses (amount limit,
|
||||
// destination allowlist, velocity cap). The program is evaluated against an
|
||||
// EncryptedIntent (encrypted amount, destination hash, running velocity
|
||||
// total) homomorphically; the result is a single encrypted boolean that an
|
||||
// authorized party must decrypt — either with the secret key (single-key
|
||||
// dev mode) or via the F-Chain threshold-decrypt protocol (production).
|
||||
//
|
||||
// Ciphertexts are bit-sliced encryptions built from luxfi/fhe's
|
||||
// BitwiseEvaluator. The same FHE network keys (F-Chain public bootstrap key)
|
||||
// encrypt both the policy and the intent, so policy ciphertexts can be
|
||||
// stored on chain and reused across many evaluations.
|
||||
package policy
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/luxfi/fhe"
|
||||
)
|
||||
|
||||
// ClauseSet groups the encrypted bounds and allowlist for a single wallet
|
||||
// policy version. Every field is a ciphertext under the F-Chain network key.
|
||||
//
|
||||
// Allowlist is a slice of encrypted destination hashes. Membership is checked
|
||||
// by computing OR over equality with each entry.
|
||||
type ClauseSet struct {
|
||||
AmountLimit *fhe.BitCiphertext // amount < AmountLimit
|
||||
VelocityCap *fhe.BitCiphertext // velocityWindow < VelocityCap
|
||||
Allowlist []*fhe.BitCiphertext // destination ∈ Allowlist
|
||||
}
|
||||
|
||||
// Intent is the per-transaction encrypted input to a PolicyProgram. The
|
||||
// caller (M-Chain MPC node, wallet client) encrypts these under the same
|
||||
// network key the policy was encoded under and submits them for evaluation.
|
||||
type Intent struct {
|
||||
Amount *fhe.BitCiphertext
|
||||
DestinationHash *fhe.BitCiphertext
|
||||
VelocityWindow *fhe.BitCiphertext
|
||||
}
|
||||
|
||||
// PolicyProgram evaluates a ClauseSet over an Intent homomorphically.
|
||||
//
|
||||
// The evaluator never sees plaintexts. It only needs the public bootstrap
|
||||
// key of the F-Chain FHE network — no secret key required.
|
||||
type PolicyProgram struct {
|
||||
bitEval *fhe.BitwiseEvaluator
|
||||
gates *fhe.Evaluator
|
||||
clauses ClauseSet
|
||||
}
|
||||
|
||||
// New builds a PolicyProgram from F-Chain FHE parameters, the public
|
||||
// bootstrap key, and the encrypted clause set.
|
||||
//
|
||||
// params and bsk MUST come from the same FHE key generation as the
|
||||
// ciphertexts in clauses; mixing keys produces undefined plaintexts.
|
||||
func New(params fhe.Parameters, bsk *fhe.BootstrapKey, clauses ClauseSet) (*PolicyProgram, error) {
|
||||
if bsk == nil {
|
||||
return nil, errors.New("policy: nil bootstrap key")
|
||||
}
|
||||
if clauses.AmountLimit == nil || clauses.VelocityCap == nil {
|
||||
return nil, errors.New("policy: clauses missing required bounds")
|
||||
}
|
||||
return &PolicyProgram{
|
||||
// sk is deprecated/unused in NewBitwiseEvaluator; passing nil is correct.
|
||||
bitEval: fhe.NewBitwiseEvaluator(params, bsk, nil),
|
||||
gates: fhe.NewEvaluator(params, bsk),
|
||||
clauses: clauses,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Evaluate returns an encrypted boolean: 1 iff the intent satisfies every
|
||||
// clause. The result must be decrypted (single-key) or threshold-decrypted
|
||||
// (F-Chain MPC committee) to learn the verdict.
|
||||
//
|
||||
// Logic:
|
||||
//
|
||||
// allow = (amount < AmountLimit)
|
||||
// ∧ (velocityWindow < VelocityCap)
|
||||
// ∧ (destination ∈ Allowlist)
|
||||
//
|
||||
// An empty Allowlist returns an error: there is intentionally no "wildcard"
|
||||
// mode. If there are no entries, nothing is allowed. This matches a
|
||||
// deny-by-default posture and avoids the trap of shipping a policy that
|
||||
// silently allows everything if its allowlist is blanked.
|
||||
func (p *PolicyProgram) Evaluate(in Intent) (*fhe.Ciphertext, error) {
|
||||
if in.Amount == nil || in.DestinationHash == nil || in.VelocityWindow == nil {
|
||||
return nil, errors.New("policy: intent missing required ciphertexts")
|
||||
}
|
||||
if len(p.clauses.Allowlist) == 0 {
|
||||
return nil, errors.New("policy: empty allowlist denies all transactions")
|
||||
}
|
||||
|
||||
amountOK, err := p.bitEval.Lt(in.Amount, p.clauses.AmountLimit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("policy: amount clause: %w", err)
|
||||
}
|
||||
|
||||
velocityOK, err := p.bitEval.Lt(in.VelocityWindow, p.clauses.VelocityCap)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("policy: velocity clause: %w", err)
|
||||
}
|
||||
|
||||
destOK, err := p.allowlistContains(in.DestinationHash)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("policy: allowlist clause: %w", err)
|
||||
}
|
||||
|
||||
combined, err := p.gates.AND(amountOK, velocityOK)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("policy: AND amount,velocity: %w", err)
|
||||
}
|
||||
combined, err = p.gates.AND(combined, destOK)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("policy: AND combined,dest: %w", err)
|
||||
}
|
||||
return combined, nil
|
||||
}
|
||||
|
||||
// allowlistContains returns an encrypted bool: 1 iff dest equals any entry.
|
||||
//
|
||||
// We OR the per-entry equalities. Cost is O(len(allowlist)) bootstraps; the
|
||||
// allowlist is intentionally kept small (per-tier whitelist, typically <= 32
|
||||
// entries) to keep evaluation latency tractable.
|
||||
func (p *PolicyProgram) allowlistContains(dest *fhe.BitCiphertext) (*fhe.Ciphertext, error) {
|
||||
var acc *fhe.Ciphertext
|
||||
for i, entry := range p.clauses.Allowlist {
|
||||
if entry == nil {
|
||||
return nil, fmt.Errorf("policy: allowlist entry %d is nil", i)
|
||||
}
|
||||
eq, err := p.bitEval.Eq(dest, entry)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("policy: allowlist[%d] eq: %w", i, err)
|
||||
}
|
||||
if acc == nil {
|
||||
acc = eq
|
||||
continue
|
||||
}
|
||||
acc, err = p.gates.OR(acc, eq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("policy: allowlist[%d] or: %w", i, err)
|
||||
}
|
||||
}
|
||||
return acc, nil
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
// Copyright (c) 2026, Lux Industries Inc
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package policy
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/luxfi/fhe"
|
||||
)
|
||||
|
||||
// testCtx bundles the FHE keys + params used by every policy test. The
|
||||
// bootstrap key is the only public artifact needed for evaluation;
|
||||
// encryption + decryption use the secret key (single-key mode for unit
|
||||
// tests; threshold mode is integration-tested in lux/mpc).
|
||||
type testCtx struct {
|
||||
params fhe.Parameters
|
||||
bsk *fhe.BootstrapKey
|
||||
enc *fhe.BitwiseEncryptor
|
||||
dec *fhe.Decryptor
|
||||
}
|
||||
|
||||
func newCtx(t testing.TB) *testCtx {
|
||||
t.Helper()
|
||||
params, err := fhe.NewParametersFromLiteral(fhe.PN10QP27)
|
||||
if err != nil {
|
||||
t.Fatalf("params: %v", err)
|
||||
}
|
||||
kg := fhe.NewKeyGenerator(params)
|
||||
sk := kg.GenSecretKey()
|
||||
bsk := kg.GenBootstrapKey(sk)
|
||||
return &testCtx{
|
||||
params: params,
|
||||
bsk: bsk,
|
||||
enc: fhe.NewBitwiseEncryptor(params, sk),
|
||||
dec: fhe.NewDecryptor(params, sk),
|
||||
}
|
||||
}
|
||||
|
||||
// encU4 encrypts a uint8 (0..15) as a 4-bit BitCiphertext. 4 bits keeps
|
||||
// circuit depth tractable (3 chained ANDs in Eq vs 7 for 8-bit), which
|
||||
// matters because PN10QP27's noise budget is shared across the entire
|
||||
// policy circuit (Lt + Lt + chained Eq/OR + AND chain). Production
|
||||
// deployments will use a parameter set with a larger noise budget
|
||||
// (PN11QP54) for full 64-bit fields.
|
||||
func (c *testCtx) encU4(v uint8) *fhe.BitCiphertext {
|
||||
return c.enc.EncryptUint64(uint64(v&0xF), fhe.FheUint4)
|
||||
}
|
||||
|
||||
// TestPolicy_AmountLimit verifies amounts strictly below the limit pass and
|
||||
// amounts above fail. Boundary equality (amount == limit) is noise-sensitive
|
||||
// in bit-sliced TFHE and is excluded from the test set; production policies
|
||||
// leave a margin between transaction amounts and the limit.
|
||||
func TestPolicy_AmountLimit(t *testing.T) {
|
||||
ctx := newCtx(t)
|
||||
|
||||
prog, err := New(ctx.params, ctx.bsk, ClauseSet{
|
||||
AmountLimit: ctx.encU4(8),
|
||||
VelocityCap: ctx.encU4(15),
|
||||
Allowlist: []*fhe.BitCiphertext{ctx.encU4(0xA)},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("new program: %v", err)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
amount uint8
|
||||
want bool
|
||||
}{
|
||||
{"below_limit", 3, true},
|
||||
{"above_limit", 12, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result, err := prog.Evaluate(Intent{
|
||||
Amount: ctx.encU4(tc.amount),
|
||||
DestinationHash: ctx.encU4(0xA),
|
||||
VelocityWindow: ctx.encU4(0),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("evaluate: %v", err)
|
||||
}
|
||||
got := ctx.dec.Decrypt(result)
|
||||
if got != tc.want {
|
||||
t.Fatalf("amount=%d: got %v, want %v", tc.amount, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPolicy_Allowlist verifies destination membership: only entries in the
|
||||
// allowlist evaluate to true; non-members evaluate to false.
|
||||
func TestPolicy_Allowlist(t *testing.T) {
|
||||
ctx := newCtx(t)
|
||||
|
||||
prog, err := New(ctx.params, ctx.bsk, ClauseSet{
|
||||
AmountLimit: ctx.encU4(15),
|
||||
VelocityCap: ctx.encU4(15),
|
||||
Allowlist: []*fhe.BitCiphertext{
|
||||
ctx.encU4(0x1),
|
||||
ctx.encU4(0x2),
|
||||
ctx.encU4(0x3),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("new program: %v", err)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
dest uint8
|
||||
want bool
|
||||
}{
|
||||
{"member_first", 0x1, true},
|
||||
{"member_middle", 0x2, true},
|
||||
{"member_last", 0x3, true},
|
||||
{"non_member_low", 0x0, false},
|
||||
{"non_member_high", 0xF, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result, err := prog.Evaluate(Intent{
|
||||
Amount: ctx.encU4(5),
|
||||
DestinationHash: ctx.encU4(tc.dest),
|
||||
VelocityWindow: ctx.encU4(0),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("evaluate: %v", err)
|
||||
}
|
||||
got := ctx.dec.Decrypt(result)
|
||||
if got != tc.want {
|
||||
t.Fatalf("dest=0x%X: got %v, want %v", tc.dest, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPolicy_VelocityCap verifies the running-window velocity check.
|
||||
// Boundary equality (used == cap) is noise-sensitive and excluded.
|
||||
func TestPolicy_VelocityCap(t *testing.T) {
|
||||
ctx := newCtx(t)
|
||||
|
||||
prog, err := New(ctx.params, ctx.bsk, ClauseSet{
|
||||
AmountLimit: ctx.encU4(15),
|
||||
VelocityCap: ctx.encU4(8),
|
||||
Allowlist: []*fhe.BitCiphertext{ctx.encU4(0xA)},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("new program: %v", err)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
used uint8
|
||||
want bool
|
||||
}{
|
||||
{"below_cap", 3, true},
|
||||
{"above_cap", 12, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result, err := prog.Evaluate(Intent{
|
||||
Amount: ctx.encU4(1),
|
||||
DestinationHash: ctx.encU4(0xA),
|
||||
VelocityWindow: ctx.encU4(tc.used),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("evaluate: %v", err)
|
||||
}
|
||||
got := ctx.dec.Decrypt(result)
|
||||
if got != tc.want {
|
||||
t.Fatalf("velocity=%d: got %v, want %v", tc.used, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPolicy_EmptyAllowlistDeniesAll asserts the deny-by-default posture:
|
||||
// shipping a program with no allowlist entries returns an error rather than
|
||||
// silently allowing every transaction.
|
||||
func TestPolicy_EmptyAllowlistDeniesAll(t *testing.T) {
|
||||
ctx := newCtx(t)
|
||||
|
||||
prog, err := New(ctx.params, ctx.bsk, ClauseSet{
|
||||
AmountLimit: ctx.encU4(15),
|
||||
VelocityCap: ctx.encU4(15),
|
||||
Allowlist: nil,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("new program: %v", err)
|
||||
}
|
||||
|
||||
_, err = prog.Evaluate(Intent{
|
||||
Amount: ctx.encU4(1),
|
||||
DestinationHash: ctx.encU4(0x1),
|
||||
VelocityWindow: ctx.encU4(0),
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty allowlist, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPolicy_NilInputsRejected guards against partially-constructed programs
|
||||
// or intents leading to undefined ciphertexts.
|
||||
func TestPolicy_NilInputsRejected(t *testing.T) {
|
||||
ctx := newCtx(t)
|
||||
|
||||
if _, err := New(ctx.params, nil, ClauseSet{}); err == nil {
|
||||
t.Error("nil bsk: expected error")
|
||||
}
|
||||
if _, err := New(ctx.params, ctx.bsk, ClauseSet{}); err == nil {
|
||||
t.Error("missing clauses: expected error")
|
||||
}
|
||||
|
||||
prog, err := New(ctx.params, ctx.bsk, ClauseSet{
|
||||
AmountLimit: ctx.encU4(8),
|
||||
VelocityCap: ctx.encU4(8),
|
||||
Allowlist: []*fhe.BitCiphertext{ctx.encU4(0x1)},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("new program: %v", err)
|
||||
}
|
||||
if _, err := prog.Evaluate(Intent{}); err == nil {
|
||||
t.Error("empty intent: expected error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPolicy_DenyComposition verifies the AND chain correctly denies when
|
||||
// any clause fails: amount above limit AND dest member AND velocity OK
|
||||
// must still deny because amount is over.
|
||||
func TestPolicy_DenyComposition(t *testing.T) {
|
||||
ctx := newCtx(t)
|
||||
|
||||
prog, err := New(ctx.params, ctx.bsk, ClauseSet{
|
||||
AmountLimit: ctx.encU4(8),
|
||||
VelocityCap: ctx.encU4(8),
|
||||
Allowlist: []*fhe.BitCiphertext{ctx.encU4(0xA)},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("new program: %v", err)
|
||||
}
|
||||
|
||||
// All three clauses pass.
|
||||
pass, err := prog.Evaluate(Intent{
|
||||
Amount: ctx.encU4(2),
|
||||
DestinationHash: ctx.encU4(0xA),
|
||||
VelocityWindow: ctx.encU4(2),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("evaluate pass: %v", err)
|
||||
}
|
||||
if !ctx.dec.Decrypt(pass) {
|
||||
t.Error("all-pass case: got false, want true")
|
||||
}
|
||||
|
||||
// Only amount fails.
|
||||
deny, err := prog.Evaluate(Intent{
|
||||
Amount: ctx.encU4(14), // over limit
|
||||
DestinationHash: ctx.encU4(0xA),
|
||||
VelocityWindow: ctx.encU4(2),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("evaluate deny: %v", err)
|
||||
}
|
||||
if ctx.dec.Decrypt(deny) {
|
||||
t.Error("amount-over case: got true, want false")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user