Files
chains/mpcvm/attest_nft.go
zeekayandHanzo Dev b9f95308a8 mpcvm: custody answers to the transport, not to the request body
Signing authority was resolved from a name the requester wrote about itself.
CrossChainRequest already receives the sender's chain id from the transport,
which authenticated it, and then passed req.RequestingChain to the permission
lookup instead -- so the proven identity sat unused in the signature while a
field the sender controlled decided what it was allowed to do. The stock table
grants B-Chain sign, keygen and reshare at 100k/day, so the claim was worth
having.

Caller carries an authenticated chain and nothing else can be substituted for
it: the only constructor takes the transport's chain id, the fields are
unexported, and the zero value grants nothing, so a path that forgets to
authenticate fails closed instead of quietly inheriting rights. Making it a type
rather than a convention is what turns "remember not to read the body" into
something the compiler checks -- it listed every call site that had been passing
a claim.

Authorization binds to the chain ID, never to the label. A label is an
operator's convenience and anyone can spell it; the id is the identity consensus
itself uses. An entry with no chainId is bound to no chain and grants nothing,
and Initialize names those entries at boot, because silence there would read as
"custody is configured" right up until the first release failed.

HTTP keygen and sign are gone rather than guarded. That transport authenticates
no chain, so there was no check to write -- a body can say it is the bridge as
easily as it can say anything else. Ceremonies are requested by chains over the
cross-chain path; the HTTP surface reads replicated state, which needs no
identity.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-20 10:47:08 -07:00

83 lines
3.5 KiB
Go

// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package mpcvm
// attest_nft.go — the NFT-ownership attestation domain on the M-Chain.
//
// This is the seam between an external NFT collection and a node's right to
// activate an entitlement-gated chain. Someone who can read the collection
// asserts a fact — address O holds token T, and binds it to node N — and M
// threshold-signs it. The node verifies that signature and nothing else: it
// never reads Ethereum, never holds the token, and never talks to M.
//
// The token is not moved, wrapped or mirrored. The attestation is a statement
// ABOUT the token, so the collection stays the single source of truth and an
// entitlement stays revocable (stop re-attesting) and transferable (attest the
// new owner) without any on-chain migration.
//
// Mirrors attest_bridge.go: additive domain registration, then one entry point
// that computes the domain-bound payload and runs the ceremony. It differs in
// reusing the QuantumAttestation payload (ComputeAttestationPayload) rather than
// defining a parallel digest, so DetectEquivocation and VerifyAttestation apply
// to ownership attestations for free.
//
// Who is allowed to assert the fact is deliberately NOT decided here — it is
// M-Chain's existing per-key permission table (requestingChain + keyID), the same
// authority that governs bridge releases. Swapping the asserter therefore never
// touches this file, and never touches the node.
import (
"context"
"fmt"
"github.com/luxfi/chains/ownership"
)
// DomainNFTOwnership registers the ownership domain with the attestation domain
// registry. The name and the separator both come from chains/ownership, which is
// also what every verifier hashes — one constant, so M and its verifiers cannot
// drift into signing and checking different bytes.
const DomainNFTOwnership AttestationDomain = ownership.Domain
func init() {
// Additive registration; does not touch existing domains.
domainSeparators[DomainNFTOwnership] = []byte(ownership.DomainTag)
}
// AttestNFTOwnership threshold-signs an ownership claim and returns it in the
// portable, self-describing form a verifier consumes — the claim, the quorum that
// signed, and the group key, so verification needs no callback to M.
//
// The claim's Block is used as the attestation epoch. That makes the temporal
// binding exactly the block the ownership read was taken at, which gives
// equivocation a precise meaning: two different facts about one node at one block
// are contradictory and slashable (DetectEquivocation), while re-attesting the
// same node at a LATER block is the legitimate way ownership changes hands.
func (vm *VM) AttestNFTOwnership(
ctx context.Context,
by Caller,
keyID string,
claim ownership.Claim,
) (*ownership.Attestation, error) {
qa, err := vm.attest(ctx, by, keyID, DomainNFTOwnership, claim.Subject(), claim.Root(), claim.Block)
if err != nil {
return nil, fmt.Errorf("mpcvm: attest ownership of token %d for node %x: %w", claim.Token, claim.Node, err)
}
// The group key travels with the attestation so a verifier needs no
// round-trip. Failing to read it is fatal rather than papered over with a nil
// key: an attestation nobody can verify is worse than an error.
pub, err := vm.PublicKey(keyID)
if err != nil {
return nil, err
}
return &ownership.Attestation{
Claim: claim,
Epoch: claim.Block,
Signers: len(qa.Signers),
Quorum: qa.Policy.K,
Signature: qa.Signature,
GroupKey: pub,
}, nil
}