identityvm: migrate block/credential wire json→native ZAP (I-Chain codec-free)

Block, Credential, CredentialProof, Identity, ServiceEndpoint, RevocationEntry
all encode as fixed-offset ZAP objects (nested lists via u32-lens + concat blob,
mpcvm/bridgevm idiom). blockID = sha256(canonical ZAP wire). block.Bytes(),
computeID, and the 3 vm.go block-parse sites + identity/revocation persistence
all native ZAP; dropped encoding/json + encoding/binary from block.go.
HOLDOUT (correct): Credential.Claims is a schemaless map[string]interface{} VC
document — carried as a canonical key-sorted JSON blob inside a ZAP bytes slot
(encoding arbitrary dynamic JSON 'natively' = re-introducing a reflection codec).
Structural wire is 100% ZAP. 34/34 tests green (round-trip all fields,
determinism incl multi-key claims canonicalization, VM parse-block path).

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-07-14 15:35:52 -07:00
co-authored by Hanzo Dev
parent 3769a8ab27
commit c7e0109555
4 changed files with 926 additions and 44 deletions
+13 -37
View File
@@ -6,8 +6,6 @@ package identityvm
import (
"context"
"crypto/sha256"
"encoding/binary"
"encoding/json"
"errors"
"time"
@@ -18,13 +16,13 @@ import (
// Block represents a block in the IdentityVM chain
type Block struct {
ParentID_ ids.ID `json:"parentId"`
BlockHeight uint64 `json:"height"`
BlockTimestamp int64 `json:"timestamp"`
Credentials []*Credential `json:"credentials"`
ParentID_ ids.ID `json:"parentId"`
BlockHeight uint64 `json:"height"`
BlockTimestamp int64 `json:"timestamp"`
Credentials []*Credential `json:"credentials"`
Revocations []*RevocationEntry `json:"revocations,omitempty"`
Identities []*Identity `json:"identities,omitempty"`
StateRoot []byte `json:"stateRoot"`
Identities []*Identity `json:"identities,omitempty"`
StateRoot []byte `json:"stateRoot"`
// Cached values
ID_ ids.ID
@@ -41,33 +39,11 @@ func (b *Block) ID() ids.ID {
return b.ID_
}
// computeID computes the block ID
// computeID computes the block ID as the hash of the canonical ZAP wire.
// The wire commits to every block field, so identical logical blocks yield
// identical IDs and any field change moves the ID.
func (b *Block) computeID() ids.ID {
h := sha256.New()
h.Write(b.ParentID_[:])
binary.Write(h, binary.BigEndian, b.BlockHeight)
binary.Write(h, binary.BigEndian, b.BlockTimestamp)
// Include credential IDs
for _, cred := range b.Credentials {
credID := cred.ID
h.Write(credID[:])
}
// Include revocation IDs
for _, rev := range b.Revocations {
h.Write(rev.CredentialID[:])
}
// Include identity IDs
for _, identity := range b.Identities {
h.Write(identity.ID[:])
}
// Include state root
h.Write(b.StateRoot)
return ids.ID(h.Sum(nil))
return ids.ID(sha256.Sum256(b.Bytes()))
}
// ParentID returns the parent block ID
@@ -184,8 +160,8 @@ func (b *Block) Accept(ctx context.Context) error {
for _, cred := range b.Credentials {
b.vm.credentials[cred.ID] = cred
// Persist credential
credBytes, _ := json.Marshal(cred)
// Persist credential (native ZAP wire)
credBytes := marshalCredential(cred)
credKey := append(credentialPrefix, cred.ID[:]...)
b.vm.db.Put(credKey, credBytes)
@@ -244,7 +220,7 @@ func (b *Block) Bytes() []byte {
return b.bytes
}
bytes, err := json.Marshal(b)
bytes, err := b.Marshal()
if err != nil {
return nil
}
+7 -7
View File
@@ -280,7 +280,7 @@ func (vm *VM) loadLastAccepted() error {
}
var block Block
if err := json.Unmarshal(blockBytes, &block); err != nil {
if err := parseBlock(blockBytes, &block); err != nil {
return err
}
@@ -385,7 +385,7 @@ func (vm *VM) BuildBlock(ctx context.Context) (chain.Block, error) {
// ParseBlock implements chain.ChainVM
func (vm *VM) ParseBlock(ctx context.Context, blockBytes []byte) (chain.Block, error) {
var block Block
if err := json.Unmarshal(blockBytes, &block); err != nil {
if err := parseBlock(blockBytes, &block); err != nil {
return nil, err
}
@@ -413,7 +413,7 @@ func (vm *VM) GetBlock(ctx context.Context, blockID ids.ID) (chain.Block, error)
}
var block Block
if err := json.Unmarshal(blockBytes, &block); err != nil {
if err := parseBlock(blockBytes, &block); err != nil {
return nil, err
}
@@ -464,8 +464,8 @@ func (vm *VM) CreateIdentity(publicKey []byte, metadata map[string]string) (*Ide
vm.identities[identityID] = identity
// Persist
identityBytes, _ := json.Marshal(identity)
// Persist (native ZAP wire)
identityBytes := marshalIdentity(identity)
key := append(identityPrefix, identityID[:]...)
if err := vm.db.Put(key, identityBytes); err != nil {
return nil, err
@@ -605,8 +605,8 @@ func (vm *VM) RevokeCredential(credID ids.ID, revokerID ids.ID, reason string) e
vm.revocations[credID] = revocation
// Persist revocation
revBytes, _ := json.Marshal(revocation)
// Persist revocation (native ZAP wire)
revBytes := marshalRevocation(revocation)
key := append(revocationPrefix, credID[:]...)
return vm.db.Put(key, revBytes)
}
+661
View File
@@ -0,0 +1,661 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package identityvm
import (
"encoding/json"
"fmt"
"sort"
"time"
"github.com/luxfi/ids"
"github.com/luxfi/zap"
)
// Native-ZAP struct-is-wire for I-Chain (identityvm). No pcodecs, no
// reflection, no codec registry — each persisted block/credential type owns
// its Marshal/parse over a zap object. Re-genesis authorized, so the
// on-disk/wire format is these offsets (canonical: parse rejects trailing
// bytes). blockID = sha256(Block.Marshal); determinism follows from the
// deterministic wire (fixed offsets, ordered slices, sorted map keys).
//
// Nested lists (a block's credentials/revocations/identities, an identity's
// service endpoints) use the "wire-object blob + u32 length list" encoding —
// each element is its own standalone zap message, concatenated into a bytes
// field alongside a parallel list of per-element lengths, re-parsed on read.
// This is the same native-nested idiom as chains/mpcvm and chains/bridgevm.
//
// ONE json holdout, isolated to a single field: Credential.Claims is
// map[string]interface{} — an arbitrary dynamic verifiable-credential
// document with no fixed schema. It rides the wire as a canonical JSON byte
// string (Go's encoding/json sorts map keys, so the bytes are deterministic).
// Encoding arbitrary dynamic JSON "natively" would mean re-introducing
// exactly the reflection codec this migration kills; instead the claims are
// treated as one opaque, self-describing blob in a ZAP bytes slot. See
// marshalClaims/parseClaims. Every structural block/credential field is
// native ZAP.
//
// Boundaries kept as JSON (NOT encoded here): Genesis (config boundary,
// vm.go ParseGenesis), Issuer persistence (genesis-adjacent state, symmetric
// with its JSON genesis representation), and the RPC/service reply types in
// service.go. None of those are block/credential wire.
// ---- ServiceEndpoint (nested in Identity) ----
//
// ID bytes @ 0
// Type bytes @ 8
// Endpoint bytes @ 16
const (
seID = 0
seType = 8
seEndp = 16
seSize = 24
)
func marshalServiceEndpoint(s ServiceEndpoint) []byte {
b := zap.NewBuilder(zap.HeaderSize + seSize + len(s.ID) + len(s.Type) + len(s.ServiceEndpoint) + 32)
ob := b.StartObject(seSize)
ob.SetBytes(seID, []byte(s.ID))
ob.SetBytes(seType, []byte(s.Type))
ob.SetBytes(seEndp, []byte(s.ServiceEndpoint))
ob.FinishAsRoot()
return b.Finish()
}
func marshalServiceEndpoints(ss []ServiceEndpoint) [][]byte {
if len(ss) == 0 {
return nil
}
out := make([][]byte, len(ss))
for i := range ss {
out[i] = marshalServiceEndpoint(ss[i])
}
return out
}
func readServiceEndpoint(o zap.Object) ServiceEndpoint {
return ServiceEndpoint{
ID: string(o.Bytes(seID)),
Type: string(o.Bytes(seType)),
ServiceEndpoint: string(o.Bytes(seEndp)),
}
}
// ---- CredentialProof (optional, nested in Credential) ----
//
// Type bytes @ 0
// Created bytes @ 8
// VerificationMethod bytes @ 16
// ProofPurpose bytes @ 24
// ProofValue bytes @ 32
// ZKProof bytes @ 40
const (
cpType = 0
cpCreated = 8
cpVerMeth = 16
cpPurpose = 24
cpProofVl = 32
cpZKProof = 40
cpSize = 48
)
func marshalProof(p *CredentialProof) []byte {
b := zap.NewBuilder(zap.HeaderSize + cpSize + len(p.Type) + len(p.Created) +
len(p.VerificationMethod) + len(p.ProofPurpose) + len(p.ProofValue) + len(p.ZKProof) + 64)
ob := b.StartObject(cpSize)
ob.SetBytes(cpType, []byte(p.Type))
ob.SetBytes(cpCreated, []byte(p.Created))
ob.SetBytes(cpVerMeth, []byte(p.VerificationMethod))
ob.SetBytes(cpPurpose, []byte(p.ProofPurpose))
ob.SetBytes(cpProofVl, p.ProofValue)
ob.SetBytes(cpZKProof, p.ZKProof)
ob.FinishAsRoot()
return b.Finish()
}
func readProof(o zap.Object) *CredentialProof {
return &CredentialProof{
Type: string(o.Bytes(cpType)),
Created: string(o.Bytes(cpCreated)),
VerificationMethod: string(o.Bytes(cpVerMeth)),
ProofPurpose: string(o.Bytes(cpPurpose)),
ProofValue: appendBytes(o.Bytes(cpProofVl)),
ZKProof: appendBytes(o.Bytes(cpZKProof)),
}
}
// ---- Identity ----
//
// ID 32B @ 0
// Created i64 @ 32 (UnixNano)
// Updated i64 @ 40 (UnixNano)
// DID bytes @ 48
// PublicKey bytes @ 56
// Controllers bytes @ 64 (concatenated 32-byte ids)
// SvcLens list @ 72 (u32 per ServiceEndpoint wire length)
// SvcBlob bytes @ 80 (concatenated ServiceEndpoint wire objects)
// MetaLens list @ 88 (u32 per metadata string; entries are k0,v0,k1,v1,…)
// MetaBlob bytes @ 96 (concatenated metadata strings, key-sorted)
const (
idnID = 0
idnCreated = 32
idnUpdated = 40
idnDID = 48
idnPubKey = 56
idnCtrls = 64
idnSvcLens = 72
idnSvcBlob = 80
idnMetaLens = 88
idnMetaBlob = 96
idnSize = 104
)
func marshalIdentity(id *Identity) []byte {
svcLens, svcBlob := packBlobs(marshalServiceEndpoints(id.Services))
metaLens, metaBlob := packMetadata(id.Metadata)
ctrls := concatIDs(id.Controllers)
b := zap.NewBuilder(zap.HeaderSize + idnSize + len(id.DID) + len(id.PublicKey) +
len(ctrls) + len(svcBlob) + 4*len(svcLens) + len(metaBlob) + 4*len(metaLens) + 128)
svcLensOff := writeU32List(b, svcLens)
metaLensOff := writeU32List(b, metaLens)
ob := b.StartObject(idnSize)
ob.SetBytesFixed(idnID, id.ID[:])
ob.SetInt64(idnCreated, id.Created.UnixNano())
ob.SetInt64(idnUpdated, id.Updated.UnixNano())
ob.SetBytes(idnDID, []byte(id.DID))
ob.SetBytes(idnPubKey, id.PublicKey)
ob.SetBytes(idnCtrls, ctrls)
ob.SetList(idnSvcLens, svcLensOff, len(svcLens))
ob.SetBytes(idnSvcBlob, svcBlob)
ob.SetList(idnMetaLens, metaLensOff, len(metaLens))
ob.SetBytes(idnMetaBlob, metaBlob)
ob.FinishAsRoot()
return b.Finish()
}
func readIdentity(o zap.Object) (*Identity, error) {
id := &Identity{
DID: string(o.Bytes(idnDID)),
PublicKey: appendBytes(o.Bytes(idnPubKey)),
Created: unixNano(o.Int64(idnCreated)),
Updated: unixNano(o.Int64(idnUpdated)),
Metadata: unpackMetadata(readU32List(o, idnMetaLens), o.Bytes(idnMetaBlob)),
}
copy(id.ID[:], o.BytesFixedSlice(idnID, 32))
id.Controllers = splitIDs(o.Bytes(idnCtrls))
svcBlobs, err := splitBlobs(readU32List(o, idnSvcLens), o.Bytes(idnSvcBlob))
if err != nil {
return nil, err
}
if len(svcBlobs) > 0 {
id.Services = make([]ServiceEndpoint, 0, len(svcBlobs))
for _, sb := range svcBlobs {
smsg, err := zap.Parse(sb)
if err != nil {
return nil, err
}
id.Services = append(id.Services, readServiceEndpoint(smsg.Root()))
}
}
return id, nil
}
func parseIdentity(data []byte) (*Identity, error) {
msg, err := zap.Parse(data)
if err != nil {
return nil, err
}
if msg.Size() != len(data) {
return nil, fmt.Errorf("identityvm identity: trailing bytes")
}
return readIdentity(msg.Root())
}
// ---- Credential ----
//
// ID 32B @ 0
// Issuer 32B @ 32
// Subject 32B @ 64
// IssuanceDate i64 @ 96 (UnixNano)
// ExpirationDate i64 @ 104 (UnixNano)
// RevocationIndex u64 @ 112
// Status bytes @ 120
// Claims bytes @ 128 (canonical JSON; see marshalClaims)
// TypeLens list @ 136 (u32 per Type string)
// TypeBlob bytes @ 144 (concatenated Type strings)
// Proof bytes @ 152 (CredentialProof wire; empty = nil)
const (
crID = 0
crIssuer = 32
crSubject = 64
crIssuance = 96
crExpire = 104
crRevIndex = 112
crStatus = 120
crClaims = 128
crTypeLens = 136
crTypeBlob = 144
crProof = 152
crSize = 160
)
func marshalCredential(c *Credential) []byte {
typeLens, typeBlob := packStrings(c.Type)
claims := marshalClaims(c.Claims)
var proof []byte
if c.Proof != nil {
proof = marshalProof(c.Proof)
}
b := zap.NewBuilder(zap.HeaderSize + crSize + len(c.Status) + len(claims) +
len(typeBlob) + 4*len(typeLens) + len(proof) + 128)
typeLensOff := writeU32List(b, typeLens)
ob := b.StartObject(crSize)
ob.SetBytesFixed(crID, c.ID[:])
ob.SetBytesFixed(crIssuer, c.Issuer[:])
ob.SetBytesFixed(crSubject, c.Subject[:])
ob.SetInt64(crIssuance, c.IssuanceDate.UnixNano())
ob.SetInt64(crExpire, c.ExpirationDate.UnixNano())
ob.SetUint64(crRevIndex, c.RevocationIndex)
ob.SetBytes(crStatus, []byte(c.Status))
ob.SetBytes(crClaims, claims)
ob.SetList(crTypeLens, typeLensOff, len(typeLens))
ob.SetBytes(crTypeBlob, typeBlob)
ob.SetBytes(crProof, proof)
ob.FinishAsRoot()
return b.Finish()
}
func readCredential(o zap.Object) (*Credential, error) {
c := &Credential{
IssuanceDate: unixNano(o.Int64(crIssuance)),
ExpirationDate: unixNano(o.Int64(crExpire)),
RevocationIndex: o.Uint64(crRevIndex),
Status: string(o.Bytes(crStatus)),
Claims: parseClaims(o.Bytes(crClaims)),
Type: unpackStrings(readU32List(o, crTypeLens), o.Bytes(crTypeBlob)),
}
copy(c.ID[:], o.BytesFixedSlice(crID, 32))
copy(c.Issuer[:], o.BytesFixedSlice(crIssuer, 32))
copy(c.Subject[:], o.BytesFixedSlice(crSubject, 32))
if pb := o.Bytes(crProof); len(pb) > 0 {
pmsg, err := zap.Parse(pb)
if err != nil {
return nil, err
}
c.Proof = readProof(pmsg.Root())
}
return c, nil
}
func parseCredential(data []byte) (*Credential, error) {
msg, err := zap.Parse(data)
if err != nil {
return nil, err
}
if msg.Size() != len(data) {
return nil, fmt.Errorf("identityvm credential: trailing bytes")
}
return readCredential(msg.Root())
}
// ---- RevocationEntry ----
//
// CredentialID 32B @ 0
// RevokedBy 32B @ 32
// RevokedAt i64 @ 64 (UnixNano)
// Reason bytes @ 72
const (
rvCredID = 0
rvBy = 32
rvAt = 64
rvReason = 72
rvSize = 80
)
func marshalRevocation(r *RevocationEntry) []byte {
b := zap.NewBuilder(zap.HeaderSize + rvSize + len(r.Reason) + 32)
ob := b.StartObject(rvSize)
ob.SetBytesFixed(rvCredID, r.CredentialID[:])
ob.SetBytesFixed(rvBy, r.RevokedBy[:])
ob.SetInt64(rvAt, r.RevokedAt.UnixNano())
ob.SetBytes(rvReason, []byte(r.Reason))
ob.FinishAsRoot()
return b.Finish()
}
func readRevocation(o zap.Object) *RevocationEntry {
r := &RevocationEntry{
RevokedAt: unixNano(o.Int64(rvAt)),
Reason: string(o.Bytes(rvReason)),
}
copy(r.CredentialID[:], o.BytesFixedSlice(rvCredID, 32))
copy(r.RevokedBy[:], o.BytesFixedSlice(rvBy, 32))
return r
}
func parseRevocation(data []byte) (*RevocationEntry, error) {
msg, err := zap.Parse(data)
if err != nil {
return nil, err
}
if msg.Size() != len(data) {
return nil, fmt.Errorf("identityvm revocation: trailing bytes")
}
return readRevocation(msg.Root()), nil
}
// ---- Block ----
//
// ParentID 32B @ 0 (ID_ is derived: sha256(Marshal); excluded)
// Height u64 @ 32
// Timestamp i64 @ 40
// StateRoot bytes @ 48
// CredLens list @ 56 (u32 per Credential wire length)
// CredBlob bytes @ 64
// RevLens list @ 72 (u32 per RevocationEntry wire length)
// RevBlob bytes @ 80
// IdnLens list @ 88 (u32 per Identity wire length)
// IdnBlob bytes @ 96
const (
blkParent = 0
blkHeight = 32
blkTime = 40
blkStateRoot = 48
blkCredLens = 56
blkCredBlob = 64
blkRevLens = 72
blkRevBlob = 80
blkIdnLens = 88
blkIdnBlob = 96
blkSize = 104
)
// Marshal encodes the block (excluding the derived ID_/cache fields) to
// canonical wire. blockID = sha256(this).
func (b *Block) Marshal() ([]byte, error) {
credBlobs := make([][]byte, len(b.Credentials))
for i, c := range b.Credentials {
credBlobs[i] = marshalCredential(c)
}
revBlobs := make([][]byte, len(b.Revocations))
for i, r := range b.Revocations {
revBlobs[i] = marshalRevocation(r)
}
idnBlobs := make([][]byte, len(b.Identities))
for i, id := range b.Identities {
idnBlobs[i] = marshalIdentity(id)
}
credLens, credBlob := packBlobs(credBlobs)
revLens, revBlob := packBlobs(revBlobs)
idnLens, idnBlob := packBlobs(idnBlobs)
bld := zap.NewBuilder(zap.HeaderSize + blkSize + len(b.StateRoot) +
len(credBlob) + len(revBlob) + len(idnBlob) +
4*(len(credLens)+len(revLens)+len(idnLens)) + 256)
credLensOff := writeU32List(bld, credLens)
revLensOff := writeU32List(bld, revLens)
idnLensOff := writeU32List(bld, idnLens)
ob := bld.StartObject(blkSize)
ob.SetBytesFixed(blkParent, b.ParentID_[:])
ob.SetUint64(blkHeight, b.BlockHeight)
ob.SetInt64(blkTime, b.BlockTimestamp)
ob.SetBytes(blkStateRoot, b.StateRoot)
ob.SetList(blkCredLens, credLensOff, len(credLens))
ob.SetBytes(blkCredBlob, credBlob)
ob.SetList(blkRevLens, revLensOff, len(revLens))
ob.SetBytes(blkRevBlob, revBlob)
ob.SetList(blkIdnLens, idnLensOff, len(idnLens))
ob.SetBytes(blkIdnBlob, idnBlob)
ob.FinishAsRoot()
return bld.Finish(), nil
}
// parseBlock fills the wire fields of blk from canonical block bytes. The
// caller sets the non-wire cache fields (vm, bytes, status).
func parseBlock(data []byte, blk *Block) error {
msg, err := zap.Parse(data)
if err != nil {
return err
}
if msg.Size() != len(data) {
return fmt.Errorf("identityvm block: trailing bytes")
}
o := msg.Root()
copy(blk.ParentID_[:], o.BytesFixedSlice(blkParent, 32))
blk.BlockHeight = o.Uint64(blkHeight)
blk.BlockTimestamp = o.Int64(blkTime)
blk.StateRoot = appendBytes(o.Bytes(blkStateRoot))
credBlobs, err := splitBlobs(readU32List(o, blkCredLens), o.Bytes(blkCredBlob))
if err != nil {
return err
}
if len(credBlobs) > 0 {
blk.Credentials = make([]*Credential, 0, len(credBlobs))
for _, cb := range credBlobs {
cmsg, err := zap.Parse(cb)
if err != nil {
return err
}
c, err := readCredential(cmsg.Root())
if err != nil {
return err
}
blk.Credentials = append(blk.Credentials, c)
}
}
revBlobs, err := splitBlobs(readU32List(o, blkRevLens), o.Bytes(blkRevBlob))
if err != nil {
return err
}
if len(revBlobs) > 0 {
blk.Revocations = make([]*RevocationEntry, 0, len(revBlobs))
for _, rb := range revBlobs {
rmsg, err := zap.Parse(rb)
if err != nil {
return err
}
blk.Revocations = append(blk.Revocations, readRevocation(rmsg.Root()))
}
}
idnBlobs, err := splitBlobs(readU32List(o, blkIdnLens), o.Bytes(blkIdnBlob))
if err != nil {
return err
}
if len(idnBlobs) > 0 {
blk.Identities = make([]*Identity, 0, len(idnBlobs))
for _, ib := range idnBlobs {
id, err := parseIdentity(ib)
if err != nil {
return err
}
blk.Identities = append(blk.Identities, id)
}
}
return nil
}
// ---- shared helpers ----
// marshalClaims canonicalizes a dynamic verifiable-credential claims document
// to deterministic bytes. Go's encoding/json sorts map keys, so the same
// logical claims map always yields identical bytes. This is the single JSON
// holdout on the credential wire: Claims is map[string]interface{}, arbitrary
// dynamic data with no fixed schema — carried as one opaque, self-describing
// blob rather than a reflective codec.
func marshalClaims(claims map[string]interface{}) []byte {
if len(claims) == 0 {
return nil
}
b, err := json.Marshal(claims)
if err != nil {
return nil
}
return b
}
func parseClaims(b []byte) map[string]interface{} {
if len(b) == 0 {
return nil
}
var m map[string]interface{}
if err := json.Unmarshal(b, &m); err != nil {
return nil
}
return m
}
func writeU32List(b *zap.Builder, xs []uint32) int {
lb := b.StartList(4)
for _, x := range xs {
lb.AddUint32(x)
}
off, _ := lb.Finish()
return off
}
func readU32List(o zap.Object, ptrOff int) []uint32 {
l := o.ListStride(ptrOff, 4)
n := l.Len()
out := make([]uint32, n)
for i := 0; i < n; i++ {
out[i] = l.Uint32(i)
}
return out
}
// packBlobs returns per-element byte lengths and the concatenated blob.
func packBlobs(blobs [][]byte) ([]uint32, []byte) {
if len(blobs) == 0 {
return nil, nil
}
lens := make([]uint32, len(blobs))
var blob []byte
for i, bz := range blobs {
lens[i] = uint32(len(bz))
blob = append(blob, bz...)
}
return lens, blob
}
// splitBlobs re-splits a concatenated blob by lens.
func splitBlobs(lens []uint32, blob []byte) ([][]byte, error) {
if len(lens) == 0 {
return nil, nil
}
out := make([][]byte, 0, len(lens))
pos := 0
for _, l := range lens {
if pos+int(l) > len(blob) {
return nil, fmt.Errorf("identityvm: sub-object blob out of bounds")
}
out = append(out, blob[pos:pos+int(l)])
pos += int(l)
}
return out, nil
}
// packStrings returns per-string byte lengths and the concatenated blob.
func packStrings(ss []string) ([]uint32, []byte) {
if len(ss) == 0 {
return nil, nil
}
lens := make([]uint32, len(ss))
var blob []byte
for i, s := range ss {
lens[i] = uint32(len(s))
blob = append(blob, s...)
}
return lens, blob
}
// unpackStrings re-splits blob by lens. Returns nil (not empty) for no entries.
func unpackStrings(lens []uint32, blob []byte) []string {
if len(lens) == 0 {
return nil
}
out := make([]string, 0, len(lens))
pos := 0
for _, l := range lens {
if pos+int(l) > len(blob) {
break
}
out = append(out, string(blob[pos:pos+int(l)]))
pos += int(l)
}
return out
}
// packMetadata packs a string map as key-sorted [k0,v0,k1,v1,…] strings —
// deterministic regardless of Go map iteration order.
func packMetadata(m map[string]string) ([]uint32, []byte) {
if len(m) == 0 {
return nil, nil
}
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
ss := make([]string, 0, 2*len(keys))
for _, k := range keys {
ss = append(ss, k, m[k])
}
return packStrings(ss)
}
func unpackMetadata(lens []uint32, blob []byte) map[string]string {
ss := unpackStrings(lens, blob)
if len(ss) == 0 {
return nil
}
m := make(map[string]string, len(ss)/2)
for i := 0; i+1 < len(ss); i += 2 {
m[ss[i]] = ss[i+1]
}
return m
}
func concatIDs(ids []ids.ID) []byte {
if len(ids) == 0 {
return nil
}
out := make([]byte, 0, 32*len(ids))
for _, id := range ids {
out = append(out, id[:]...)
}
return out
}
func splitIDs(blob []byte) []ids.ID {
n := len(blob) / 32
if n == 0 {
return nil
}
out := make([]ids.ID, n)
for i := 0; i < n; i++ {
copy(out[i][:], blob[i*32:(i+1)*32])
}
return out
}
func appendBytes(b []byte) []byte {
if len(b) == 0 {
return nil
}
return append([]byte(nil), b...)
}
// unixNano reconstructs a UTC time from a UnixNano wire value.
func unixNano(nano int64) time.Time {
return time.Unix(0, nano).UTC()
}
+245
View File
@@ -0,0 +1,245 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package identityvm
import (
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/luxfi/ids"
)
// sampleCredential is a fully-populated credential exercising every wire
// field: fixed ids, times (with sub-second precision), a dynamic claims
// document, a type list, and an optional proof.
func sampleCredential() *Credential {
return &Credential{
ID: ids.ID{1, 2, 3},
Type: []string{"VerifiableCredential", "KYCCredential"},
Issuer: ids.ID{4, 5, 6},
Subject: ids.ID{7, 8, 9},
IssuanceDate: time.Unix(1_700_000_000, 500).UTC(),
ExpirationDate: time.Unix(1_800_000_000, 0).UTC(),
Claims: map[string]interface{}{"name": "alice", "age": float64(30), "verified": true},
Status: CredentialActive,
RevocationIndex: 7,
Proof: &CredentialProof{
Type: "Ed25519Signature2020",
Created: "2026-07-14T00:00:00Z",
VerificationMethod: "did:lux:abc#key-1",
ProofPurpose: "assertionMethod",
ProofValue: []byte{0xde, 0xad, 0xbe, 0xef},
ZKProof: []byte{0x01, 0x02},
},
}
}
func sampleIdentity() *Identity {
return &Identity{
ID: ids.ID{10, 11, 12},
DID: "did:lux:0123456789abcdef",
PublicKey: []byte{0xaa, 0xbb, 0xcc, 0xdd},
Controllers: []ids.ID{{13}, {14}, {15}},
Services: []ServiceEndpoint{
{ID: "svc-1", Type: "LinkedDomains", ServiceEndpoint: "https://example.com"},
{ID: "svc-2", Type: "MessagingService", ServiceEndpoint: "https://msg.example.com"},
},
Created: time.Unix(1_650_000_000, 0).UTC(),
Updated: time.Unix(1_660_000_000, 123).UTC(),
Metadata: map[string]string{"org": "lux", "role": "issuer", "tier": "gold"},
}
}
func sampleRevocation() *RevocationEntry {
return &RevocationEntry{
CredentialID: ids.ID{1, 2, 3},
RevokedBy: ids.ID{4, 5, 6},
RevokedAt: time.Unix(1_750_000_000, 0).UTC(),
Reason: "key compromise",
}
}
func requireCredentialEqual(t *testing.T, want, got *Credential) {
t.Helper()
require.Equal(t, want.ID, got.ID)
require.Equal(t, want.Type, got.Type)
require.Equal(t, want.Issuer, got.Issuer)
require.Equal(t, want.Subject, got.Subject)
require.Equal(t, want.IssuanceDate.UnixNano(), got.IssuanceDate.UnixNano())
require.Equal(t, want.ExpirationDate.UnixNano(), got.ExpirationDate.UnixNano())
require.Equal(t, want.Claims, got.Claims)
require.Equal(t, want.Status, got.Status)
require.Equal(t, want.RevocationIndex, got.RevocationIndex)
require.Equal(t, want.Proof, got.Proof)
}
func requireIdentityEqual(t *testing.T, want, got *Identity) {
t.Helper()
require.Equal(t, want.ID, got.ID)
require.Equal(t, want.DID, got.DID)
require.Equal(t, want.PublicKey, got.PublicKey)
require.Equal(t, want.Controllers, got.Controllers)
require.Equal(t, want.Services, got.Services)
require.Equal(t, want.Created.UnixNano(), got.Created.UnixNano())
require.Equal(t, want.Updated.UnixNano(), got.Updated.UnixNano())
require.Equal(t, want.Metadata, got.Metadata)
}
func requireRevocationEqual(t *testing.T, want, got *RevocationEntry) {
t.Helper()
require.Equal(t, want.CredentialID, got.CredentialID)
require.Equal(t, want.RevokedBy, got.RevokedBy)
require.Equal(t, want.RevokedAt.UnixNano(), got.RevokedAt.UnixNano())
require.Equal(t, want.Reason, got.Reason)
}
func TestWireRoundTrip_Credential(t *testing.T) {
require := require.New(t)
c := sampleCredential()
got, err := parseCredential(marshalCredential(c))
require.NoError(err)
requireCredentialEqual(t, c, got)
// canonical: trailing byte rejected
_, err = parseCredential(append(marshalCredential(c), 0))
require.Error(err)
// no-proof credential: Proof stays nil
c.Proof = nil
got, err = parseCredential(marshalCredential(c))
require.NoError(err)
require.Nil(got.Proof)
requireCredentialEqual(t, c, got)
}
func TestWireRoundTrip_Identity(t *testing.T) {
require := require.New(t)
id := sampleIdentity()
got, err := parseIdentity(marshalIdentity(id))
require.NoError(err)
requireIdentityEqual(t, id, got)
_, err = parseIdentity(append(marshalIdentity(id), 0))
require.Error(err)
// minimal identity: empty collections come back nil
min := &Identity{ID: ids.ID{99}, DID: "did:lux:min", Created: time.Unix(1, 0).UTC(), Updated: time.Unix(2, 0).UTC()}
got, err = parseIdentity(marshalIdentity(min))
require.NoError(err)
require.Nil(got.Controllers)
require.Nil(got.Services)
require.Nil(got.Metadata)
requireIdentityEqual(t, min, got)
}
func TestWireRoundTrip_Revocation(t *testing.T) {
require := require.New(t)
r := sampleRevocation()
parsed, err := parseRevocation(marshalRevocation(r))
require.NoError(err)
requireRevocationEqual(t, r, parsed)
// canonical: trailing byte rejected
_, err = parseRevocation(append(marshalRevocation(r), 0))
require.Error(err)
}
func TestWireRoundTrip_Block(t *testing.T) {
require := require.New(t)
blk := &Block{
ID_: ids.ID{0xff}, // derived; must NOT affect wire
ParentID_: ids.ID{1, 2, 3},
BlockHeight: 42,
BlockTimestamp: 1_700_000_000,
StateRoot: []byte{0xca, 0xfe, 0xba, 0xbe},
Credentials: []*Credential{sampleCredential(), sampleCredential()},
Revocations: []*RevocationEntry{sampleRevocation()},
Identities: []*Identity{sampleIdentity()},
}
b, err := blk.Marshal()
require.NoError(err)
var got Block
require.NoError(parseBlock(b, &got))
require.Equal(blk.ParentID_, got.ParentID_)
require.Equal(blk.BlockHeight, got.BlockHeight)
require.Equal(blk.BlockTimestamp, got.BlockTimestamp)
require.Equal(blk.StateRoot, got.StateRoot)
require.Len(got.Credentials, 2)
for i := range blk.Credentials {
requireCredentialEqual(t, blk.Credentials[i], got.Credentials[i])
}
require.Len(got.Revocations, 1)
requireRevocationEqual(t, blk.Revocations[0], got.Revocations[0])
require.Len(got.Identities, 1)
requireIdentityEqual(t, blk.Identities[0], got.Identities[0])
// canonical: trailing byte rejected
require.Error(parseBlock(append(b, 0), &got))
// empty block: nested slices come back nil, StateRoot nil
empty := &Block{ParentID_: ids.ID{5}, BlockHeight: 1, BlockTimestamp: 2}
eb, err := empty.Marshal()
require.NoError(err)
var egot Block
require.NoError(parseBlock(eb, &egot))
require.Nil(egot.Credentials)
require.Nil(egot.Revocations)
require.Nil(egot.Identities)
require.Nil(egot.StateRoot)
require.Equal(uint64(1), egot.BlockHeight)
require.Equal(int64(2), egot.BlockTimestamp)
}
func TestWireDeterminism_Block(t *testing.T) {
require := require.New(t)
build := func() *Block {
return &Block{
ParentID_: ids.ID{1, 2, 3},
BlockHeight: 42,
BlockTimestamp: 1_700_000_000,
StateRoot: []byte{0xca, 0xfe},
Credentials: []*Credential{sampleCredential()},
Revocations: []*RevocationEntry{sampleRevocation()},
Identities: []*Identity{sampleIdentity()},
}
}
b1, err := build().Marshal()
require.NoError(err)
b2, err := build().Marshal()
require.NoError(err)
require.Equal(b1, b2, "same logical block must marshal to identical bytes")
// blockID = hash of wire, stable across independent constructions
require.Equal(build().ID(), build().ID())
// a changed field moves the id
other := build()
other.BlockHeight = 43
require.NotEqual(build().ID(), other.ID())
}
func TestWireDeterminism_Credential(t *testing.T) {
require := require.New(t)
// Multi-key claims + multi-key identity metadata: Go map iteration order
// is randomized, so identical bytes prove key-sorted canonicalization.
c := &Credential{
ID: ids.ID{1},
Type: []string{"A", "B", "C"},
Claims: map[string]interface{}{"z": float64(1), "a": "x", "m": true, "b": float64(2)},
Status: CredentialActive,
}
require.Equal(marshalCredential(c), marshalCredential(c))
id := sampleIdentity()
require.Equal(marshalIdentity(id), marshalIdentity(id))
}