mirror of
https://github.com/luxfi/chains.git
synced 2026-09-24 00:26:00 +00:00
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:
+36
-60
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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") }
|
||||
@@ -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
@@ -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
@@ -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() }
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user