identityvm: a credential is recorded with its block or not at all

Accept set the status and moved lastAccepted before writing anything, then
issued one Put per credential with the error DROPPED on the credential writes
themselves. A failure partway left earlier credentials on disk, the tip already
advanced, and the chain believing a block it had only half applied.

It now goes through the shared store: the credentials are staged, committed in
one batch with the block and the tip, and the revocations, identities and
status become visible only afterwards. A failure discards all of it and leaves
the tip where it was. The dropped error is checked.

Beside the fix, what this chain kept restating goes away: the pending queue
becomes the shared pool, the blocks in flight and the tip come from the store
under its one lock, the fee declaration is a value rather than a policy plus a
network id, and the factory no longer half-populates a VM that Initialize
rebuilds anyway.

Two things change that were not behaviour anyone could rely on. Genesis was
stamped with time.Now, so its id — the hash of its own fields — differed on
every node and every restart; it now comes from genesis itself, as on K and F.
And the per-VM GPU session the factory allocated was never read and never
released.

Restoring the original Accept fails all three atomicity tests.
This commit is contained in:
2026-08-28 08:23:46 -07:00
parent 6220024728
commit 338d59e0cd
8 changed files with 400 additions and 289 deletions
+36 -60
View File
@@ -9,11 +9,15 @@ import (
"errors"
"time"
"github.com/luxfi/chains/chain"
"github.com/luxfi/consensus/core/choices"
"github.com/luxfi/database"
"github.com/luxfi/ids"
"github.com/luxfi/log"
)
var _ chain.Block = (*Block)(nil)
// Block represents a block in the IdentityVM chain
type Block struct {
ParentID_ ids.ID `json:"parentId"`
@@ -134,88 +138,60 @@ func (b *Block) verifyCredential(cred *Credential) error {
return nil
}
// Accept accepts the block
// Accept applies the block. The store commits everything below in one batch,
// so a credential that cannot be written takes the whole block with it rather
// than leaving the chain holding half of one.
func (b *Block) Accept(ctx context.Context) error {
b.status = choices.Accepted
return b.vm.chain.Accept(b)
}
b.vm.mu.Lock()
defer b.vm.mu.Unlock()
// Update VM state
b.vm.lastAccepted = b
b.vm.lastAcceptedID = b.ID()
// Save last accepted
id := b.ID()
if err := b.vm.db.Put(lastAcceptedKey, id[:]); err != nil {
return err
}
// Save block
blockBytes := b.Bytes()
if blockBytes == nil {
return errors.New("failed to serialize block")
}
if err := b.vm.db.Put(id[:], blockBytes); err != nil {
return err
}
// Process credentials
// Write records every credential the block carries. The error a Put returns
// used to be dropped here, which meant a credential silently missing from the
// database while the chain went on believing the block had been applied.
func (b *Block) Write(db database.Database) error {
for _, cred := range b.Credentials {
b.vm.credentials[cred.ID] = cred
// Persist credential (native ZAP wire)
credBytes := marshalCredential(cred)
credKey := append(credentialPrefix, cred.ID[:]...)
b.vm.db.Put(credKey, credBytes)
// Remove from pending
for i, pending := range b.vm.pendingCreds {
if pending.ID == cred.ID {
b.vm.pendingCreds = append(b.vm.pendingCreds[:i], b.vm.pendingCreds[i+1:]...)
break
}
if err := db.Put(credentialKey(cred.ID), marshalCredential(cred)); err != nil {
return err
}
}
return nil
}
// Process revocations
// Publish makes the block's effects visible: the credentials it carries, the
// revocations it applies, the identities it introduces, and the block's own
// status. It runs after the commit, so nothing here can be believed and then
// lost — which is what setting them first did.
func (b *Block) Publish() {
vm := b.vm
for _, cred := range b.Credentials {
vm.credentials[cred.ID] = cred
}
for _, rev := range b.Revocations {
b.vm.revocations[rev.CredentialID] = rev
// Update credential status
if cred, ok := b.vm.credentials[rev.CredentialID]; ok {
vm.revocations[rev.CredentialID] = rev
if cred, ok := vm.credentials[rev.CredentialID]; ok {
cred.Status = CredentialRevoked
}
}
// Process new identities
for _, identity := range b.Identities {
b.vm.identities[identity.ID] = identity
vm.identities[identity.ID] = identity
}
// Remove from pending blocks
delete(b.vm.pendingBlocks, b.ID())
vm.pending.Drop(b.Credentials)
b.status = choices.Accepted
b.vm.log.Info("Block accepted",
vm.log.Info("Block accepted",
log.Uint64("height", b.BlockHeight),
log.String("id", b.ID().String()),
log.Int("credentials", len(b.Credentials)),
)
return nil
}
// Reject rejects the block
// Reject discards the block. It wrote nothing, so there is nothing to undo;
// its credentials stay queued for a later block.
func (b *Block) Reject(ctx context.Context) error {
b.status = choices.Rejected
b.vm.mu.Lock()
defer b.vm.mu.Unlock()
// Remove from pending blocks
delete(b.vm.pendingBlocks, b.ID())
b.vm.chain.Drop(b.ID())
return nil
}
+226
View File
@@ -0,0 +1,226 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package identityvm
import (
"context"
"encoding/json"
"errors"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/luxfi/database"
"github.com/luxfi/database/memdb"
"github.com/luxfi/ids"
"github.com/luxfi/log"
"github.com/luxfi/runtime"
vmcore "github.com/luxfi/vm"
)
// refusingDB is a real database whose flush to disk can be made to fail. That
// is how a block fails in the field: the writes are complete and staged, and
// making them durable is what does not happen.
type refusingDB struct {
database.Database
refuse bool
}
func (d *refusingDB) NewBatch() database.Batch {
return &refusingBatch{Batch: d.Database.NewBatch(), db: d}
}
type refusingBatch struct {
database.Batch
db *refusingDB
}
var errRefused = errors.New("flush refused")
func (b *refusingBatch) Write() error {
if b.db.refuse {
return errRefused
}
return b.Batch.Write()
}
func newRefusingVM(t *testing.T) (*VM, *refusingDB) {
t.Helper()
db := &refusingDB{Database: memdb.New()}
genesisBytes, err := json.Marshal(&Genesis{
Timestamp: 1,
Config: &Config{CredentialTTL: 3600, MaxClaims: 50, AllowSelfIssue: true},
})
require.NoError(t, err)
vm := &VM{}
require.NoError(t, vm.Initialize(context.Background(), vmcore.Init{
Runtime: &runtime.Runtime{ChainID: ids.GenerateTestID(), Log: log.NewNoOpLogger()},
DB: db,
Genesis: genesisBytes,
ToEngine: make(chan vmcore.Message, 10),
}))
return vm, db
}
// TestABlockThatCannotCommitLeavesNothingBehind is the defect this chain had.
// Accept used to set the status, move lastAccepted, and then issue one Put per
// credential, each returning early — so a failure partway through left the
// earlier credentials on disk with the chain already believing the block was
// applied, and no way back.
//
// Now every write of the block is staged and committed together. When the
// commit fails, none of it lands, the chain does not move, and none of the
// block's records become visible.
func TestABlockThatCannotCommitLeavesNothingBehind(t *testing.T) {
vm, db := newRefusingVM(t)
defer vm.Shutdown(context.Background())
subject, err := vm.CreateIdentity([]byte("subject"), nil)
require.NoError(t, err)
issuer, err := vm.RegisterIssuer("issuer", []byte("issuer-key"), []string{"T"}, 1)
require.NoError(t, err)
var creds []*Credential
for i := 0; i < 3; i++ {
c, err := vm.IssueCredential(issuer.ID, subject.ID, []string{"T"},
map[string]interface{}{"i": i}, time.Hour)
require.NoError(t, err)
creds = append(creds, c)
}
arrival := &Identity{ID: ids.GenerateTestID(), DID: "did:lux:arrival"}
revoked := &RevocationEntry{CredentialID: creds[0].ID, RevokedBy: issuer.ID}
built, err := vm.BuildBlock(context.Background())
require.NoError(t, err)
blk := built.(*Block)
blk.Identities = []*Identity{arrival}
blk.Revocations = []*RevocationEntry{revoked}
tipBefore, heightBefore := vm.chain.Tip()
queuedBefore := vm.pending.Len()
db.refuse = true
require.ErrorIs(t, blk.Accept(context.Background()), errRefused)
// Nothing the block wrote is on disk — not even the first credential, which
// under the old code always landed because its Put ran before any failure
// could occur.
for i, c := range creds {
has, err := db.Database.Has(credentialKey(c.ID))
require.NoError(t, err)
require.Falsef(t, has, "credential %d must not survive a block that did not commit", i)
}
has, err := db.Database.Has(tipKeyForTest())
require.NoError(t, err)
require.False(t, has, "the tip pointer must not survive either")
// And the chain does not believe the block happened.
tip, height := vm.chain.Tip()
require.Equal(t, tipBefore, tip)
require.Equal(t, heightBefore, height)
require.NotEqual(t, uint8(3), blk.Status(), "a block that did not commit is not accepted")
// None of the block's records became visible.
vm.chain.RLock()
_, sawIdentity := vm.identities[arrival.ID]
_, sawRevocation := vm.revocations[revoked.CredentialID]
status := vm.credentials[creds[0].ID].Status
vm.chain.RUnlock()
require.False(t, sawIdentity, "an identity the block introduced must not appear")
require.False(t, sawRevocation, "nor a revocation it applied")
require.Equal(t, CredentialActive, status, "nor the status change that came with it")
// The credentials are still queued, so the work is not lost either.
require.Equal(t, queuedBefore, vm.pending.Len())
}
// TestTheChainRecoversFromABlockItCouldNotCommit proves the discarded writes
// are really discarded rather than merely unflushed: if they were still
// staged, they would ride along on whatever commits next.
func TestTheChainRecoversFromABlockItCouldNotCommit(t *testing.T) {
vm, db := newRefusingVM(t)
defer vm.Shutdown(context.Background())
subject, _ := vm.CreateIdentity([]byte("subject"), nil)
issuer, _ := vm.RegisterIssuer("issuer", []byte("issuer-key"), []string{"T"}, 1)
doomed, err := vm.IssueCredential(issuer.ID, subject.ID, []string{"T"},
map[string]interface{}{"n": 1}, time.Hour)
require.NoError(t, err)
blk, err := vm.BuildBlock(context.Background())
require.NoError(t, err)
db.refuse = true
require.ErrorIs(t, blk.Accept(context.Background()), errRefused)
// A second block, which does commit.
db.refuse = false
vm.pending.Drop([]*Credential{doomed})
kept, err := vm.IssueCredential(issuer.ID, subject.ID, []string{"T"},
map[string]interface{}{"n": 2}, time.Hour)
require.NoError(t, err)
next, err := vm.BuildBlock(context.Background())
require.NoError(t, err)
require.NoError(t, next.Accept(context.Background()))
has, err := db.Database.Has(credentialKey(doomed.ID))
require.NoError(t, err)
require.False(t, has, "the abandoned block's credential did not ride along")
has, err = db.Database.Has(credentialKey(kept.ID))
require.NoError(t, err)
require.True(t, has, "the block that committed did land")
tip, height := vm.chain.Tip()
require.Equal(t, next.ID(), tip)
require.Equal(t, uint64(1), height)
}
// TestAnAcceptedBlockIsDurableAndVisible is the other half: what commits is on
// disk and in memory together.
func TestAnAcceptedBlockIsDurableAndVisible(t *testing.T) {
vm, db := newRefusingVM(t)
defer vm.Shutdown(context.Background())
subject, _ := vm.CreateIdentity([]byte("subject"), nil)
issuer, _ := vm.RegisterIssuer("issuer", []byte("issuer-key"), []string{"T"}, 1)
cred, err := vm.IssueCredential(issuer.ID, subject.ID, []string{"T"}, nil, time.Hour)
require.NoError(t, err)
arrival := &Identity{ID: ids.GenerateTestID(), DID: "did:lux:arrival"}
built, err := vm.BuildBlock(context.Background())
require.NoError(t, err)
blk := built.(*Block)
blk.Identities = []*Identity{arrival}
require.NoError(t, blk.Accept(context.Background()))
has, err := db.Database.Has(credentialKey(cred.ID))
require.NoError(t, err)
require.True(t, has)
tip, height := vm.chain.Tip()
require.Equal(t, blk.ID(), tip)
require.Equal(t, uint64(1), height)
require.Zero(t, vm.pending.Len(), "the credential it carried is no longer waiting")
vm.chain.RLock()
_, sawIdentity := vm.identities[arrival.ID]
vm.chain.RUnlock()
require.True(t, sawIdentity)
// The block is readable back by id and by height.
got, err := vm.GetBlock(context.Background(), blk.ID())
require.NoError(t, err)
require.Equal(t, blk.ID(), got.ID())
}
// tipKeyForTest names the store's tip pointer. The test asserts on the key the
// store actually writes rather than on one it hopes matches.
func tipKeyForTest() []byte { return []byte("chain/tip") }
+2 -2
View File
@@ -78,11 +78,11 @@ func TestRequireZKProofsCannotBeSkippedByOmittingTheProof(t *testing.T) {
withProof := credentialExpiringAt(time.Now().Add(time.Hour))
withProof.Proof = &CredentialProof{Type: "zk", ZKProof: []byte("proof-bytes")}
vm.mu.Lock()
vm.chain.Lock()
for _, c := range []*Credential{noProof, emptyProof, withProof} {
vm.credentials[c.ID] = c
}
vm.mu.Unlock()
vm.chain.Unlock()
ok, err := vm.VerifyCredential(noProof.ID)
require.False(ok, "a credential with no proof must not satisfy RequireZKProofs")
+3 -26
View File
@@ -4,35 +4,12 @@
package identityvm
import (
"github.com/luxfi/accel"
"github.com/luxfi/chains/chain"
"github.com/luxfi/ids"
"github.com/luxfi/log"
"github.com/luxfi/node/vms"
)
var _ vms.Factory = (*Factory)(nil)
// VMID is the unique identifier for IdentityVM (I-Chain)
var VMID = ids.ID{'i', 'd', 'e', 'n', 't', 'i', 't', 'y', 'v', 'm'}
// Factory creates new IdentityVM instances
type Factory struct{}
// New returns a new instance of the IdentityVM.
// Allocates a per-VM GPU session at PriorityNormal for future batch
// credential signature verification.
func (f *Factory) New(logger log.Logger) (interface{}, error) {
sess, err := accel.NewVMSession("identityvm", accel.WithPriority(accel.PriorityNormal))
if err != nil {
return nil, err
}
return &VM{
accel: sess,
identities: make(map[ids.ID]*Identity),
credentials: make(map[ids.ID]*Credential),
issuers: make(map[ids.ID]*Issuer),
revocations: make(map[ids.ID]*RevocationEntry),
pendingCreds: make([]*Credential, 0),
pendingBlocks: make(map[ids.ID]*Block),
}, nil
}
// Factory creates new IdentityVM instances.
type Factory = chain.Factory[VM]
+10 -29
View File
@@ -4,37 +4,18 @@
package identityvm
import (
"fmt"
"github.com/luxfi/constants"
"github.com/luxfi/node/vms/types/fee"
)
// newFeePolicy returns the canonical I-Chain FeePolicy. I-Chain accepts
// user-submitted DID + credential RPCs that produce on-chain effects,
// so it MUST charge a non-zero floor; see vms/types/fee/policy.go.
func newFeePolicy(networkID uint32) fee.Policy {
return fee.FlatPolicy{
Fee: fee.MinTxFeeFloor,
AssetID: constants.UTXOAssetIDFor(networkID),
}
}
// gateUserFee refuses paidFee below MinTxFeeFloor. Called from each
// mutating service RPC (CreateIdentity, IssueCredential,
// RevokeCredential, CreateProof, RegisterIssuer) before the
// state-modifying VM method runs.
// gateUserFee refuses paidFee below the chain's declared floor. Called from
// each mutating service RPC (CreateIdentity, IssueCredential,
// RevokeCredential, CreateProof, RegisterIssuer) before the state-modifying VM
// method runs.
//
// Read-only RPCs (GetIdentity, GetCredential, VerifyCredential,
// GetIssuer, ListIssuers, ResolveIdentity, Health) are not gated.
func (vm *VM) gateUserFee(paidFee uint64) error {
if vm.feePolicy == nil {
return fmt.Errorf("identityvm: fee policy not initialized")
}
asset := constants.UTXOAssetIDFor(vm.networkID)
return vm.feePolicy.ValidateFee(paidFee, asset)
}
// Read-only RPCs (GetIdentity, GetCredential, VerifyCredential, GetIssuer,
// ListIssuers, ResolveIdentity, Health) are not gated.
func (vm *VM) gateUserFee(paidFee uint64) error { return vm.fee.Admit(paidFee) }
// FeePolicy exposes the chain's declared fee policy for diagnostics
// and the boot-time Validate gate.
func (vm *VM) FeePolicy() fee.Policy { return vm.feePolicy }
// FeePolicy exposes the chain's declared fee policy for diagnostics and the
// boot-time Validate gate.
func (vm *VM) FeePolicy() fee.Policy { return vm.fee.Policy() }
+4 -4
View File
@@ -449,8 +449,8 @@ type ListIssuersReply struct {
// ListIssuers lists all issuers
func (s *Service) ListIssuers(r *http.Request, args *ListIssuersArgs, reply *ListIssuersReply) error {
s.vm.mu.RLock()
defer s.vm.mu.RUnlock()
s.vm.chain.RLock()
defer s.vm.chain.RUnlock()
reply.Issuers = make([]IssuerReply, 0, len(s.vm.issuers))
@@ -507,8 +507,8 @@ func (s *Service) Health(r *http.Request, args *HealthArgs, reply *HealthReply)
return err
}
s.vm.mu.RLock()
defer s.vm.mu.RUnlock()
s.vm.chain.RLock()
defer s.vm.chain.RUnlock()
reply.Healthy = health.Healthy
reply.Identities = len(s.vm.identities)
+118 -161
View File
@@ -11,22 +11,20 @@ import (
"errors"
"fmt"
"net/http"
"sync"
"time"
"github.com/gorilla/rpc/v2"
grjson "github.com/gorilla/rpc/v2/json"
"github.com/luxfi/accel"
"github.com/luxfi/chains/chain"
"github.com/luxfi/consensus/core/choices"
"github.com/luxfi/database"
"github.com/luxfi/ids"
"github.com/luxfi/log"
"github.com/luxfi/node/vms/artifacts"
"github.com/luxfi/node/vms/types/fee"
"github.com/luxfi/runtime"
vmcore "github.com/luxfi/vm"
"github.com/luxfi/vm/chain"
vmchain "github.com/luxfi/vm/chain"
)
const (
@@ -44,9 +42,8 @@ const (
)
var (
_ chain.ChainVM = (*VM)(nil)
_ vmchain.ChainVM = (*VM)(nil)
lastAcceptedKey = []byte("lastAccepted")
identityPrefix = []byte("id:")
credentialPrefix = []byte("cred:")
issuerPrefix = []byte("issuer:")
@@ -132,41 +129,36 @@ type RevocationEntry struct {
Reason string `json:"reason"`
}
// MaxPendingCredentials bounds the queue of credentials waiting for a block.
// Issuing is open to any authorized issuer, so without a bound the queue is
// whatever they choose to make it.
const MaxPendingCredentials = 4096
// VM implements the IdentityVM for decentralized identity
type VM struct {
rt *runtime.Runtime
config Config
log log.Logger
db database.Database
// Per-VM GPU acceleration session. Reserved for future batch
// credential signature verification and DID proof checks.
accel *accel.VMSession
// chain is the durable state, the blocks in flight and the tip — and the
// one lock over all of it, which the record caches below share. Take it
// with chain.Lock or chain.RLock.
chain *chain.Store
// State
identities map[ids.ID]*Identity
credentials map[ids.ID]*Credential
issuers map[ids.ID]*Issuer
revocations map[ids.ID]*RevocationEntry
pendingCreds []*Credential
pendingBlocks map[ids.ID]*Block
// Record caches, under the chain's lock.
identities map[ids.ID]*Identity
credentials map[ids.ID]*Credential
issuers map[ids.ID]*Issuer
revocations map[ids.ID]*RevocationEntry
// work tells consensus a block can be built, which is what issuing a
// credential produces.
work vmcore.Latch
// pending holds the credentials waiting for a block, and tells consensus
// there is one to build. LOCK ORDER: the chain's lock, then the pool's.
pending *chain.Pool[*Credential]
// Consensus
lastAccepted *Block
lastAcceptedID ids.ID
// Fee policy gating user-submitted mutating RPCs (CreateIdentity,
// IssueCredential, RevokeCredential, CreateProof,
// RegisterIssuer). FlatPolicy at MinTxFeeFloor; consensus-
// internal callers bypass via direct VM methods.
feePolicy fee.Policy
networkID uint32
mu sync.RWMutex
// fee is what this chain charges to admit a mutating RPC. I-Chain accepts
// user-submitted DID and credential calls that produce on-chain effects,
// so it declares the floor rather than admitting free work.
fee chain.Fee
// RPC
rpcServer *rpc.Server
@@ -178,7 +170,6 @@ func (vm *VM) Initialize(
vmInit vmcore.Init,
) error {
vm.rt = vmInit.Runtime
vm.db = vmInit.DB
if logger, ok := vm.rt.Log.(log.Logger); ok {
vm.log = logger
@@ -186,12 +177,15 @@ func (vm *VM) Initialize(
return errors.New("invalid logger type")
}
// The caches are rebuilt only here: a block publishes its records after its
// writes have committed, so a block that fails leaves them untouched and
// there is nothing to roll back.
vm.chain = chain.New(vmInit.DB, nil)
vm.identities = make(map[ids.ID]*Identity)
vm.credentials = make(map[ids.ID]*Credential)
vm.issuers = make(map[ids.ID]*Issuer)
vm.revocations = make(map[ids.ID]*RevocationEntry)
vm.pendingCreds = make([]*Credential, 0)
vm.pendingBlocks = make(map[ids.ID]*Block)
vm.pending = chain.NewPool(MaxPendingCredentials, func(c *Credential) ids.ID { return c.ID })
// Parse genesis
genesis, err := ParseGenesis(vmInit.Genesis)
@@ -225,19 +219,26 @@ func (vm *VM) Initialize(
vm.rpcServer.RegisterCodec(grjson.NewCodec(), "application/json;charset=UTF-8")
vm.rpcServer.RegisterService(&Service{vm: vm}, "identity")
// Pin fee policy. I-Chain accepts user mutating RPCs so attach
// the canonical FlatPolicy at MinTxFeeFloor; fee.Validate
// refuses zero-fee user-facing chains at boot.
// I-Chain accepts user mutating RPCs, so it declares the floor; the node's
// boot-time Validate refuses a zero-fee user-facing chain.
var networkID uint32
if vm.rt != nil {
vm.networkID = vm.rt.NetworkID
networkID = vm.rt.NetworkID
}
vm.feePolicy = newFeePolicy(vm.networkID)
if err := fee.Validate(vm.feePolicy); err != nil {
vm.fee = chain.Floor(networkID)
if err := fee.Validate(vm.fee.Policy()); err != nil {
return fmt.Errorf("identityvm: fee policy: %w", err)
}
// Load last accepted block
if err := vm.loadLastAccepted(); err != nil {
// Genesis is at height 0, stamped with the time genesis itself declares.
// Reading the wall clock here would give every node a different genesis id
// for the same chain, since the id is the hash of the block's own fields.
genesisBlock := &Block{
BlockTimestamp: genesis.Timestamp,
vm: vm,
status: choices.Accepted,
}
if _, _, err := vm.chain.Open(genesisBlock, vm.parseBlock); err != nil {
return err
}
@@ -259,42 +260,23 @@ func (vm *VM) Initialize(
return nil
}
// loadLastAccepted loads the last accepted block from the database
func (vm *VM) loadLastAccepted() error {
lastAcceptedBytes, err := vm.db.Get(lastAcceptedKey)
if err == database.ErrNotFound {
vm.lastAccepted = &Block{
BlockHeight: 0,
BlockTimestamp: time.Now().Unix(),
vm: vm,
status: choices.Accepted,
}
vm.lastAcceptedID = vm.lastAccepted.ID()
return nil
}
if err != nil {
return err
}
var blockID ids.ID
copy(blockID[:], lastAcceptedBytes)
blockBytes, err := vm.db.Get(blockID[:])
if err != nil {
return err
}
// parseBlock decodes a block belonging to this VM. The store uses it to read
// back the tip and any accepted block, so there is one decoder rather than one
// per call site.
func (vm *VM) parseBlock(raw []byte) (chain.Block, error) {
var block Block
if err := parseBlock(blockBytes, &block); err != nil {
return err
if err := parseBlock(raw, &block); err != nil {
return nil, err
}
block.vm = vm
block.bytes = raw
block.status = choices.Accepted
vm.lastAccepted = &block
vm.lastAcceptedID = blockID
return &block, nil
}
return nil
// credentialKey is where a credential is recorded.
func credentialKey(id ids.ID) []byte {
return append(append([]byte(nil), credentialPrefix...), id[:]...)
}
// SetState implements chain.ChainVM
@@ -333,11 +315,11 @@ func (vm *VM) CreateHandlers(ctx context.Context) (map[string]http.Handler, erro
}
// HealthCheck implements chain.ChainVM
func (vm *VM) HealthCheck(ctx context.Context) (chain.HealthResult, error) {
vm.mu.RLock()
defer vm.mu.RUnlock()
func (vm *VM) HealthCheck(ctx context.Context) (vmchain.HealthResult, error) {
vm.chain.RLock()
defer vm.chain.RUnlock()
return chain.HealthResult{
return vmchain.HealthResult{
Healthy: true,
Details: map[string]string{
"identities": fmt.Sprintf("%d", len(vm.identities)),
@@ -353,7 +335,7 @@ func (vm *VM) Version(ctx context.Context) (string, error) {
}
// Connected implements chain.ChainVM
func (vm *VM) Connected(ctx context.Context, nodeID ids.NodeID, nodeVersion *chain.VersionInfo) error {
func (vm *VM) Connected(ctx context.Context, nodeID ids.NodeID, nodeVersion *vmchain.VersionInfo) error {
vm.log.Debug("Node connected", log.String("nodeID", nodeID.String()))
return nil
}
@@ -364,36 +346,33 @@ func (vm *VM) Disconnected(ctx context.Context, nodeID ids.NodeID) error {
return nil
}
// BuildBlock implements chain.ChainVM
func (vm *VM) BuildBlock(ctx context.Context) (chain.Block, error) {
vm.mu.Lock()
defer vm.mu.Unlock()
// A block with nothing in it says nothing and still has to be voted on.
if len(vm.pendingCreds) == 0 {
return nil, errNothingToBuild
// BuildBlock implements chain.ChainVM. Reading the tip and registering the
// block on it happen in one step, so nothing can be accepted in between and
// leave the proposal hanging off a parent that is no longer the tip.
func (vm *VM) BuildBlock(ctx context.Context) (vmchain.Block, error) {
built, err := vm.chain.Propose(func(parent ids.ID, height uint64) (chain.Block, error) {
// A block with nothing in it says nothing and still has to be voted on.
creds := vm.pending.Take(0)
if len(creds) == 0 {
return nil, errNothingToBuild
}
return &Block{
ParentID_: parent,
BlockHeight: height + 1,
BlockTimestamp: time.Now().Unix(),
Credentials: creds,
vm: vm,
status: choices.Processing,
}, nil
})
if err != nil {
return nil, err
}
// Copy pending credentials to avoid slice mutation issues during Accept
creds := make([]*Credential, len(vm.pendingCreds))
copy(creds, vm.pendingCreds)
block := &Block{
ParentID_: vm.lastAcceptedID,
BlockHeight: vm.lastAccepted.BlockHeight + 1,
BlockTimestamp: time.Now().Unix(),
Credentials: creds,
vm: vm,
status: choices.Processing,
}
vm.pendingBlocks[block.ID()] = block
return block, nil
return built, nil
}
// ParseBlock implements chain.ChainVM
func (vm *VM) ParseBlock(ctx context.Context, blockBytes []byte) (chain.Block, error) {
func (vm *VM) ParseBlock(ctx context.Context, blockBytes []byte) (vmchain.Block, error) {
var block Block
if err := parseBlock(blockBytes, &block); err != nil {
return nil, err
@@ -406,32 +385,8 @@ func (vm *VM) ParseBlock(ctx context.Context, blockBytes []byte) (chain.Block, e
}
// GetBlock implements chain.ChainVM
func (vm *VM) GetBlock(ctx context.Context, blockID ids.ID) (chain.Block, error) {
vm.mu.RLock()
// Check pending blocks (nil-safe for early calls before initialization)
if vm.pendingBlocks != nil {
if block, ok := vm.pendingBlocks[blockID]; ok {
vm.mu.RUnlock()
return block, nil
}
}
vm.mu.RUnlock()
blockBytes, err := vm.db.Get(blockID[:])
if err != nil {
return nil, err
}
var block Block
if err := parseBlock(blockBytes, &block); err != nil {
return nil, err
}
block.vm = vm
block.bytes = blockBytes
block.status = choices.Accepted
return &block, nil
func (vm *VM) GetBlock(ctx context.Context, blockID ids.ID) (vmchain.Block, error) {
return vm.chain.Block(blockID, vm.parseBlock)
}
// SetPreference implements chain.ChainVM
@@ -441,17 +396,16 @@ func (vm *VM) SetPreference(ctx context.Context, blockID ids.ID) error {
// LastAccepted implements chain.ChainVM
func (vm *VM) LastAccepted(ctx context.Context) (ids.ID, error) {
vm.mu.RLock()
defer vm.mu.RUnlock()
return vm.lastAcceptedID, nil
id, _ := vm.chain.Tip()
return id, nil
}
// ======== Identity Management ========
// CreateIdentity creates a new decentralized identity
func (vm *VM) CreateIdentity(publicKey []byte, metadata map[string]string) (*Identity, error) {
vm.mu.Lock()
defer vm.mu.Unlock()
vm.chain.Lock()
defer vm.chain.Unlock()
// Generate identity ID from public key
h := sha256.New()
@@ -477,7 +431,7 @@ func (vm *VM) CreateIdentity(publicKey []byte, metadata map[string]string) (*Ide
// Persist (native ZAP wire)
identityBytes := marshalIdentity(identity)
key := append(identityPrefix, identityID[:]...)
if err := vm.db.Put(key, identityBytes); err != nil {
if err := vm.chain.Base().Put(key, identityBytes); err != nil {
return nil, err
}
@@ -486,8 +440,8 @@ func (vm *VM) CreateIdentity(publicKey []byte, metadata map[string]string) (*Ide
// GetIdentity returns an identity by ID
func (vm *VM) GetIdentity(identityID ids.ID) (*Identity, error) {
vm.mu.RLock()
defer vm.mu.RUnlock()
vm.chain.RLock()
defer vm.chain.RUnlock()
identity, ok := vm.identities[identityID]
if !ok {
@@ -498,8 +452,8 @@ func (vm *VM) GetIdentity(identityID ids.ID) (*Identity, error) {
// ResolveIdentity resolves an identity by DID
func (vm *VM) ResolveIdentity(did string) (*Identity, error) {
vm.mu.RLock()
defer vm.mu.RUnlock()
vm.chain.RLock()
defer vm.chain.RUnlock()
for _, identity := range vm.identities {
if identity.DID == did {
@@ -513,8 +467,8 @@ func (vm *VM) ResolveIdentity(did string) (*Identity, error) {
// IssueCredential issues a new verifiable credential
func (vm *VM) IssueCredential(issuerID, subjectID ids.ID, credType []string, claims map[string]interface{}, ttl time.Duration) (*Credential, error) {
vm.mu.Lock()
defer vm.mu.Unlock()
vm.chain.Lock()
defer vm.chain.Unlock()
// Verify issuer exists and is authorized
issuer, ok := vm.issuers[issuerID]
@@ -561,18 +515,21 @@ func (vm *VM) IssueCredential(issuerID, subjectID ids.ID, credType []string, cla
}
vm.credentials[credID] = cred
vm.pendingCreds = append(vm.pendingCreds, cred)
// Consensus builds nothing until it is told there is something to build.
vm.work.Signal()
// Queueing tells consensus there is a block to build; a chain builds
// nothing until it is told.
if err := vm.pending.Add(cred); err != nil {
delete(vm.credentials, credID)
return nil, err
}
return cred, nil
}
// GetCredential returns a credential by ID
func (vm *VM) GetCredential(credID ids.ID) (*Credential, error) {
vm.mu.RLock()
defer vm.mu.RUnlock()
vm.chain.RLock()
defer vm.chain.RUnlock()
cred, ok := vm.credentials[credID]
if !ok {
@@ -594,8 +551,8 @@ func (vm *VM) GetCredential(credID ids.ID) (*Credential, error) {
// RevokeCredential revokes a credential
func (vm *VM) RevokeCredential(credID ids.ID, revokerID ids.ID, reason string) error {
vm.mu.Lock()
defer vm.mu.Unlock()
vm.chain.Lock()
defer vm.chain.Unlock()
cred, ok := vm.credentials[credID]
if !ok {
@@ -621,13 +578,13 @@ func (vm *VM) RevokeCredential(credID ids.ID, revokerID ids.ID, reason string) e
// Persist revocation (native ZAP wire)
revBytes := marshalRevocation(revocation)
key := append(revocationPrefix, credID[:]...)
return vm.db.Put(key, revBytes)
return vm.chain.Base().Put(key, revBytes)
}
// VerifyCredential verifies a credential is valid
func (vm *VM) VerifyCredential(credID ids.ID) (bool, error) {
vm.mu.RLock()
defer vm.mu.RUnlock()
vm.chain.RLock()
defer vm.chain.RUnlock()
cred, ok := vm.credentials[credID]
if !ok {
@@ -663,8 +620,8 @@ func (vm *VM) VerifyCredential(credID ids.ID) (bool, error) {
// CreateCredentialProof creates a CredentialProof artifact
func (vm *VM) CreateCredentialProof(credID ids.ID, zkProof []byte, selectiveDisclosure []string) (*artifacts.CredentialProof, error) {
vm.mu.RLock()
defer vm.mu.RUnlock()
vm.chain.RLock()
defer vm.chain.RUnlock()
cred, ok := vm.credentials[credID]
if !ok {
@@ -722,15 +679,15 @@ func (vm *VM) GetBlockIDAtHeight(ctx context.Context, height uint64) (ids.ID, er
// VM stops. Waiting only on the context would mean BuildBlock is never called
// and the chain never leaves genesis, however many credentials are issued.
func (vm *VM) WaitForEvent(ctx context.Context) (vmcore.Message, error) {
return vm.work.WaitForEvent(ctx)
return vm.pending.Wait(ctx)
}
// ======== Issuer Management ========
// RegisterIssuer registers a new credential issuer
func (vm *VM) RegisterIssuer(name string, publicKey []byte, types []string, trustLevel int) (*Issuer, error) {
vm.mu.Lock()
defer vm.mu.Unlock()
vm.chain.Lock()
defer vm.chain.Unlock()
// Generate issuer ID
h := sha256.New()
@@ -752,7 +709,7 @@ func (vm *VM) RegisterIssuer(name string, publicKey []byte, types []string, trus
// Persist
issuerBytes, _ := json.Marshal(issuer)
key := append(issuerPrefix, issuerID[:]...)
if err := vm.db.Put(key, issuerBytes); err != nil {
if err := vm.chain.Base().Put(key, issuerBytes); err != nil {
return nil, err
}
@@ -761,8 +718,8 @@ func (vm *VM) RegisterIssuer(name string, publicKey []byte, types []string, trus
// GetIssuer returns an issuer by ID
func (vm *VM) GetIssuer(issuerID ids.ID) (*Issuer, error) {
vm.mu.RLock()
defer vm.mu.RUnlock()
vm.chain.RLock()
defer vm.chain.RUnlock()
issuer, ok := vm.issuers[issuerID]
if !ok {
+1 -7
View File
@@ -46,8 +46,6 @@ func TestVMInitialize(t *testing.T) {
credentials: make(map[ids.ID]*Credential),
issuers: make(map[ids.ID]*Issuer),
revocations: make(map[ids.ID]*RevocationEntry),
pendingCreds: make([]*Credential, 0),
pendingBlocks: make(map[ids.ID]*Block),
}
genesis := &Genesis{
@@ -619,9 +617,7 @@ func TestBlockWithCredentials(t *testing.T) {
require.NoError(err)
// Pending credentials should be cleared
vm.mu.RLock()
require.Empty(vm.pendingCreds)
vm.mu.RUnlock()
require.Zero(vm.pending.Len())
}
// setupTestVM creates and initializes a test VM
@@ -633,8 +629,6 @@ func setupTestVM(t *testing.T) *VM {
credentials: make(map[ids.ID]*Credential),
issuers: make(map[ids.ID]*Issuer),
revocations: make(map[ids.ID]*RevocationEntry),
pendingCreds: make([]*Credential, 0),
pendingBlocks: make(map[ids.ID]*Block),
}
genesis := &Genesis{