mirror of
https://github.com/luxfi/chains.git
synced 2026-09-24 00:26:00 +00:00
B/M: a security crossing is its own act
M threshold-signs a security crossing the way it signs a fungible transfer, and B carries the attestation to the destination register. Same leaderless ceremony, same authorisation, same group key travelling with the result so B verifies without asking M anything. What differs is the message, and therefore the digest. What B does here is narrower than what it does for a fungible asset. The fungible path releases by minting: a confirmed lock authorises N units to an account and nothing else has to be true. Whether a SECURITY may exist on the destination at all is the destination register's question — is this holder verified, do their claims satisfy the token, does a holding period follow the paper across — and the token asks it in the block that lands the position. B never decides a security may land. It decides the attestation is genuine and says what it claims to say. SecurityRequest is separate from BridgeRequest rather than a flag on it. The two are released by different calls onto the destination, and a mint against a register nobody asked is exactly the failure this path exists to prevent; one type that could be either leaves that difference to a branch somebody has to remember to write. Two fields the destination cannot look up for itself travel with the crossing — the holder's ONCHAINID on the source register, and when they first received there. Without the second, an arriving position anchors its clock to now, and a holding period a crossing resets is one a crossing washes off. A short identity is refused rather than read as an absence: treating it as absent lets a truncated field pass for "this holder had none". An absent one travels as zero, which is honest. Quantity is a uint256. A billion shares at eighteen decimals is an ordinary size for a register and does not fit the uint64 the fungible path carries; a truncated quantity is a position that arrives smaller than it left, so it is refused rather than truncated. Signer ids convert to strings at the boundary so a verifier on the far side needs nothing of M's type graph. The crossing type itself is aliased from internal/bridgeattest rather than copied — attest_bridge.go keeps its own copy of BridgeTransfer, and two definitions kept in step by hand is a way for a digest to drift across a boundary whose whole value is that it cannot.
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package bridgevm
|
||||
|
||||
// security.go — the crossing path for paper that is a security.
|
||||
//
|
||||
// The fungible path releases by minting: a confirmed lock on the source
|
||||
// authorises N units to an account on the destination, and nothing else has to
|
||||
// be true. A security cannot land that way. Whether the position may exist on
|
||||
// the destination at all is the destination register's question — is this holder
|
||||
// verified, do their claims satisfy the token, does a holding period follow the
|
||||
// paper across — and that question is asked by the token, in the block that
|
||||
// lands it, not by this chain.
|
||||
//
|
||||
// So what B does here is narrower than what it does for a fungible asset. It
|
||||
// carries a crossing that M has attested, and hands it to the destination
|
||||
// register as an ARRIVAL. The register accepts or refuses on its own terms. B
|
||||
// never decides that a security may land; it only decides that this attestation
|
||||
// is genuine and says what it claims to say.
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
|
||||
"github.com/luxfi/chains/internal/bridgeattest"
|
||||
"github.com/luxfi/ids"
|
||||
)
|
||||
|
||||
var (
|
||||
errSecurityHolderLen = errors.New("bridgevm: security holder must be 20 bytes")
|
||||
errSecurityIdentityLen = errors.New("bridgevm: security identity must be 20 bytes")
|
||||
errSecurityNoDst = errors.New("bridgevm: security crossing missing destination chain id")
|
||||
errSecurityNoQuantity = errors.New("bridgevm: security crossing has no quantity")
|
||||
errSecurityQuantityBig = errors.New("bridgevm: security quantity exceeds uint256")
|
||||
)
|
||||
|
||||
// SecurityRequest is a consensus record of a security leaving one register.
|
||||
//
|
||||
// Separate from BridgeRequest rather than a flag on it. The two are released by
|
||||
// different calls onto the destination — a mint against a register that has not
|
||||
// been asked anything is exactly the failure this path exists to prevent — and a
|
||||
// type that could be either would leave that difference to a branch somebody has
|
||||
// to remember to write.
|
||||
type SecurityRequest struct {
|
||||
ID ids.ID `json:"id"`
|
||||
SourceChain string `json:"sourceChain"`
|
||||
DestChain string `json:"destChain"`
|
||||
|
||||
SrcChainID uint32 `json:"srcChainId"`
|
||||
DstChainID uint32 `json:"dstChainId"`
|
||||
Nonce uint64 `json:"nonce"`
|
||||
|
||||
// Security is the canonical cross-chain id of the paper, not the token
|
||||
// address on either side: the same security has a different address on
|
||||
// every register it appears on.
|
||||
Security ids.ID `json:"security"`
|
||||
|
||||
// Quantity is the full uint256 the source impounded. A uint64 would cap a
|
||||
// crossing at about 18.4 tokens at eighteen decimals.
|
||||
Quantity *big.Int `json:"quantity"`
|
||||
|
||||
// Holder is the account the position lands on.
|
||||
Holder []byte `json:"holder"`
|
||||
|
||||
// Identity is the holder's ONCHAINID on the SOURCE register, and Acquired
|
||||
// is when they first received there. Both travel because the destination
|
||||
// cannot ask the source anything, and both may be absent — a source that
|
||||
// registered no identity has none to send. Absent is not zero-as-a-value:
|
||||
// the destination reads them as unknown and applies its own rule.
|
||||
Identity []byte `json:"identity"`
|
||||
Acquired uint64 `json:"acquired"`
|
||||
|
||||
SourceTxID ids.ID `json:"sourceTxId"`
|
||||
Confirmations uint32 `json:"confirmations"`
|
||||
Status BridgeRequestStatus `json:"status"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
}
|
||||
|
||||
// crossing maps a consensus security request to the canonical, domain-bound
|
||||
// value M signs and the destination register verifies.
|
||||
//
|
||||
// Everything the digest commits to is validated here rather than trusted: a
|
||||
// malformed request that reached a signature would produce an attestation
|
||||
// authorising an arrival nobody can make sense of.
|
||||
func (r *SecurityRequest) crossing() (bridgeattest.SecurityCrossing, error) {
|
||||
var zero bridgeattest.SecurityCrossing
|
||||
|
||||
if len(r.Holder) != 20 {
|
||||
return zero, fmt.Errorf("%w, got %d", errSecurityHolderLen, len(r.Holder))
|
||||
}
|
||||
if r.DstChainID == 0 {
|
||||
return zero, errSecurityNoDst
|
||||
}
|
||||
if r.Quantity == nil || r.Quantity.Sign() <= 0 {
|
||||
return zero, errSecurityNoQuantity
|
||||
}
|
||||
// FillBytes panics rather than truncating on overflow, and a silently
|
||||
// truncated quantity is a position that arrives smaller than it left.
|
||||
if r.Quantity.BitLen() > 256 {
|
||||
return zero, errSecurityQuantityBig
|
||||
}
|
||||
// An identity is twenty bytes or absent. A short one is a malformed request,
|
||||
// not an absence — treating it as absent would let a truncated field pass
|
||||
// for "this holder had no identity".
|
||||
if len(r.Identity) != 0 && len(r.Identity) != 20 {
|
||||
return zero, fmt.Errorf("%w, got %d", errSecurityIdentityLen, len(r.Identity))
|
||||
}
|
||||
|
||||
var holder, identity [20]byte
|
||||
copy(holder[:], r.Holder)
|
||||
copy(identity[:], r.Identity)
|
||||
|
||||
var quantity [32]byte
|
||||
r.Quantity.FillBytes(quantity[:])
|
||||
|
||||
return bridgeattest.SecurityCrossing{
|
||||
SrcChainID: r.SrcChainID,
|
||||
DstChainID: r.DstChainID,
|
||||
Security: [32]byte(r.Security),
|
||||
Quantity: quantity,
|
||||
Holder: holder,
|
||||
Identity: identity,
|
||||
Acquired: r.Acquired,
|
||||
Nonce: r.Nonce,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package bridgevm
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"math/big"
|
||||
"testing"
|
||||
|
||||
"github.com/luxfi/ids"
|
||||
)
|
||||
|
||||
func sampleSecurity() *SecurityRequest {
|
||||
return &SecurityRequest{
|
||||
SrcChainID: 96369,
|
||||
DstChainID: 36963,
|
||||
Nonce: 1,
|
||||
Security: ids.ID(bytes.Repeat([]byte{0xAB}, 32)),
|
||||
Quantity: new(big.Int).Mul(big.NewInt(1000), big.NewInt(1e18)),
|
||||
Holder: bytes.Repeat([]byte{0x11}, 20),
|
||||
Identity: bytes.Repeat([]byte{0x22}, 20),
|
||||
Acquired: 1_700_000_000,
|
||||
}
|
||||
}
|
||||
|
||||
// The reason this message exists rather than a widened BridgeTransfer: a
|
||||
// position of any real size overflows the uint64 the fungible path carries.
|
||||
func TestAPositionLargerThanAUint64Crosses(t *testing.T) {
|
||||
r := sampleSecurity()
|
||||
// A billion shares at eighteen decimals — far past uint64, and an ordinary
|
||||
// size for a register.
|
||||
r.Quantity = new(big.Int).Mul(big.NewInt(1_000_000_000), big.NewInt(1e18))
|
||||
|
||||
sc, err := r.crossing()
|
||||
if err != nil {
|
||||
t.Fatalf("crossing: %v", err)
|
||||
}
|
||||
if got := new(big.Int).SetBytes(sc.Quantity[:]); got.Cmp(r.Quantity) != 0 {
|
||||
t.Fatalf("quantity did not survive: got %s want %s", got, r.Quantity)
|
||||
}
|
||||
if r.Quantity.IsUint64() {
|
||||
t.Fatal("control: this test's quantity fits a uint64, so it proves nothing")
|
||||
}
|
||||
}
|
||||
|
||||
// A short identity is a malformed request, not an absence. Reading it as
|
||||
// absence would let a truncated field pass for "this holder had none".
|
||||
func TestAShortIdentityIsRefusedRatherThanTreatedAsAbsent(t *testing.T) {
|
||||
r := sampleSecurity()
|
||||
r.Identity = bytes.Repeat([]byte{0x22}, 19)
|
||||
|
||||
if _, err := r.crossing(); err == nil {
|
||||
t.Fatal("a 19-byte identity was accepted")
|
||||
}
|
||||
|
||||
// Whereas none at all is honest, and travels as zero.
|
||||
r.Identity = nil
|
||||
sc, err := r.crossing()
|
||||
if err != nil {
|
||||
t.Fatalf("an absent identity should cross: %v", err)
|
||||
}
|
||||
if sc.Identity != ([20]byte{}) {
|
||||
t.Fatal("an absent identity did not travel as zero")
|
||||
}
|
||||
}
|
||||
|
||||
// Everything the digest commits to is validated before a signature can exist,
|
||||
// because an attestation over a malformed crossing authorises an arrival nobody
|
||||
// can make sense of.
|
||||
func TestAMalformedCrossingNeverReachesASignature(t *testing.T) {
|
||||
for name, break_ := range map[string]func(*SecurityRequest){
|
||||
"no destination": func(r *SecurityRequest) { r.DstChainID = 0 },
|
||||
"short holder": func(r *SecurityRequest) { r.Holder = bytes.Repeat([]byte{1}, 19) },
|
||||
"no holder": func(r *SecurityRequest) { r.Holder = nil },
|
||||
"nil quantity": func(r *SecurityRequest) { r.Quantity = nil },
|
||||
"zero quantity": func(r *SecurityRequest) { r.Quantity = big.NewInt(0) },
|
||||
"negative amount": func(r *SecurityRequest) { r.Quantity = big.NewInt(-1) },
|
||||
"over uint256": func(r *SecurityRequest) {
|
||||
r.Quantity = new(big.Int).Lsh(big.NewInt(1), 256)
|
||||
},
|
||||
} {
|
||||
r := sampleSecurity()
|
||||
break_(r)
|
||||
if _, err := r.crossing(); err == nil {
|
||||
t.Fatalf("%s was accepted", name)
|
||||
}
|
||||
}
|
||||
|
||||
// Control: the unbroken request crosses, so the refusals above are the
|
||||
// breakage and not a mapping that refuses everything.
|
||||
if _, err := sampleSecurity().crossing(); err != nil {
|
||||
t.Fatalf("control: a well-formed crossing was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The route and the nonce are part of what one attestation authorises, so two
|
||||
// requests that differ only in those must not produce the same digest.
|
||||
func TestEachCrossingIsItsOwn(t *testing.T) {
|
||||
base, err := sampleSecurity().crossing()
|
||||
if err != nil {
|
||||
t.Fatalf("crossing: %v", err)
|
||||
}
|
||||
for name, mutate := range map[string]func(*SecurityRequest){
|
||||
"nonce": func(r *SecurityRequest) { r.Nonce++ },
|
||||
"destination": func(r *SecurityRequest) { r.DstChainID++ },
|
||||
"source": func(r *SecurityRequest) { r.SrcChainID++ },
|
||||
"holder": func(r *SecurityRequest) { r.Holder[19] ^= 1 },
|
||||
"identity": func(r *SecurityRequest) { r.Identity[19] ^= 1 },
|
||||
"acquired": func(r *SecurityRequest) { r.Acquired++ },
|
||||
"quantity": func(r *SecurityRequest) { r.Quantity = big.NewInt(1) },
|
||||
} {
|
||||
r := sampleSecurity()
|
||||
mutate(r)
|
||||
other, err := r.crossing()
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", name, err)
|
||||
}
|
||||
if other.Digest() == base.Digest() {
|
||||
t.Fatalf("%s does not change the digest, so one attestation covers both", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package mpcvm
|
||||
|
||||
// attest_security.go — the security-crossing attestation domain on the M-Chain.
|
||||
//
|
||||
// The same seam as attest_bridge.go, for paper that is a security rather than a
|
||||
// fungible asset: M threshold-signs a domain-bound crossing message, B verifies
|
||||
// it before the position lands, and B decides what the message means. M owns the
|
||||
// signature; it holds no opinion about registers.
|
||||
//
|
||||
// The message itself is bridgeattest.SecurityCrossing, aliased rather than
|
||||
// copied. attest_bridge.go keeps its own copy of BridgeTransfer and the two
|
||||
// definitions have to be kept in step by hand, which is a way for the digest to
|
||||
// drift on one side of a boundary whose whole value is that it cannot.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/luxfi/chains/internal/bridgeattest"
|
||||
)
|
||||
|
||||
// DomainSecurityCrossing registers the security domain with the attestation
|
||||
// domain registry, alongside DomainBridgeTransfer.
|
||||
const DomainSecurityCrossing AttestationDomain = "bridge/security"
|
||||
|
||||
func init() {
|
||||
domainSeparators[DomainSecurityCrossing] = []byte(bridgeattest.SecurityDomainTag)
|
||||
}
|
||||
|
||||
// SecurityCrossing is the domain-bound message B commits to when a security
|
||||
// leaves one register. Aliased from the shared package so M and B cannot
|
||||
// disagree about the preimage.
|
||||
type SecurityCrossing = bridgeattest.SecurityCrossing
|
||||
|
||||
// SecurityAttestation is M's threshold signature over a crossing, with the
|
||||
// context B needs to verify it without a round-trip.
|
||||
type SecurityAttestation = bridgeattest.SecurityAttestation
|
||||
|
||||
// AttestSecurityCrossing threshold-signs one crossing and returns the
|
||||
// attestation that authorises exactly that arrival.
|
||||
//
|
||||
// Identical in shape to AttestBridgeTransfer, and deliberately so: the caller is
|
||||
// authorised the same way, the ceremony is the same leaderless derivation, and
|
||||
// the group key travels with the result so B verifies without asking M
|
||||
// anything. What differs is only the message, and therefore the digest.
|
||||
func (vm *VM) AttestSecurityCrossing(
|
||||
ctx context.Context,
|
||||
by Caller,
|
||||
keyID string,
|
||||
sc SecurityCrossing,
|
||||
) (*SecurityAttestation, error) {
|
||||
digest := sc.Digest()
|
||||
|
||||
op, err := vm.RequestSignature(ctx, by, keyID, digest[:])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mpcvm: security attestation for %s: %w", keyID, err)
|
||||
}
|
||||
// Failing to read the group key is fatal rather than something to paper
|
||||
// over with a nil: an attestation nobody can verify is worse than an error.
|
||||
pub, err := vm.PublicKey(keyID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// party.ID is M's name for a signer; the shared attestation carries strings
|
||||
// so a verifier on the far side of the boundary — B, a relayer, Solidity —
|
||||
// needs nothing of M's type graph to read who signed.
|
||||
signers := make([]string, len(op.Signers))
|
||||
for i, id := range op.Signers {
|
||||
signers[i] = string(id)
|
||||
}
|
||||
|
||||
return &SecurityAttestation{
|
||||
Crossing: sc,
|
||||
Digest: digest,
|
||||
Signature: op.Artifact,
|
||||
GroupPubKey: pub,
|
||||
Signers: signers,
|
||||
KeyID: keyID,
|
||||
CeremonyID: op.CeremonyID,
|
||||
CreatedAt: time.Now().Unix(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// VerifySecurityAttestation is B's gate: true iff sig is a valid threshold
|
||||
// signature by the group key over THIS crossing's domain-bound digest. No
|
||||
// interaction with M — a threshold ECDSA signature verifies like a single-key
|
||||
// one.
|
||||
func VerifySecurityAttestation(groupPubKey []byte, sc SecurityCrossing, sig []byte) bool {
|
||||
return bridgeattest.VerifySecurityAttestation(groupPubKey, sc, sig)
|
||||
}
|
||||
Reference in New Issue
Block a user