identityvm: tell consensus a credential was issued

I-Chain accepted credentials and never built a block from them. WaitForEvent
answered with <-ctx.Done(), which never returns, so BuildBlock was never called
and the chain could not leave genesis however many credentials were issued.
IssueCredential now signals, and WaitForEvent is the shared latch.

BuildBlock also built a block whatever was pending, including nothing. An empty
block says nothing and still has to be voted on, and every other chain here
refuses one, so it refuses too. Three tests built a block off an empty VM as
setup rather than as a claim about empty blocks; they now issue a credential
first, which exercises more than they did before.

The liveness test fails without the signal, waiting its full timeout for a wake
that never comes.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-08-25 12:34:59 -07:00
co-authored by Hanzo Dev
parent 318794d558
commit b384ef94bb
3 changed files with 85 additions and 5 deletions
+54
View File
@@ -0,0 +1,54 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package identityvm
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/require"
)
// TestIssuingACredentialBuildsABlock is the whole path: a credential is issued,
// consensus is told there is work, and the block that gets built carries it.
//
// WaitForEvent used to wait only on the context, so it never returned, BuildBlock
// was never called, and I-Chain could not leave genesis however many credentials
// were issued.
func TestIssuingACredentialBuildsABlock(t *testing.T) {
require := require.New(t)
vm := setupTestVM(t)
defer vm.Shutdown(context.Background())
subject, err := vm.CreateIdentity([]byte("subject-key"), nil)
require.NoError(err)
// Nothing issued yet, so nothing should be claimed and nothing built.
idle, cancelIdle := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancelIdle()
_, err = vm.WaitForEvent(idle)
require.Error(err, "an idle chain reported work to build")
_, err = vm.BuildBlock(context.Background())
require.ErrorIs(err, errNothingToBuild,
"a block with nothing in it says nothing and still has to be voted on")
cred, err := vm.IssueCredential(subject.ID, subject.ID, []string{"test"},
map[string]interface{}{"claim": "value"}, time.Hour)
require.NoError(err)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err = vm.WaitForEvent(ctx)
require.NoError(err, "a credential was issued and consensus was never told")
blk, err := vm.BuildBlock(ctx)
require.NoError(err)
built, ok := blk.(*Block)
require.True(ok)
require.Len(built.Credentials, 1)
require.Equal(cred.ID, built.Credentials[0].ID,
"the block carries a different credential than the one issued")
}
+17 -5
View File
@@ -57,6 +57,7 @@ var (
errCredentialRevoked = errors.New("credential revoked")
errCredentialExpired = errors.New("credential expired")
errNotIssuer = errors.New("not authorized issuer")
errNothingToBuild = errors.New("no credentials to build a block from")
errInvalidProof = errors.New("invalid zero-knowledge proof")
)
@@ -150,6 +151,10 @@ type VM struct {
pendingCreds []*Credential
pendingBlocks map[ids.ID]*Block
// work tells consensus a block can be built, which is what issuing a
// credential produces.
work vmcore.Latch
// Consensus
lastAccepted *Block
lastAcceptedID ids.ID
@@ -364,6 +369,11 @@ 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
}
// Copy pending credentials to avoid slice mutation issues during Accept
creds := make([]*Credential, len(vm.pendingCreds))
copy(creds, vm.pendingCreds)
@@ -553,6 +563,9 @@ 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()
return cred, nil
}
@@ -705,12 +718,11 @@ func (vm *VM) GetBlockIDAtHeight(ctx context.Context, height uint64) (ids.ID, er
}
// WaitForEvent implements chain.ChainVM
// WaitForEvent blocks until there is a credential to build a block from, or the
// 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) {
// Block until context is cancelled
// In production, this would wait for credential requests, etc.
// CRITICAL: Must block here to avoid notification flood loop in chains/manager.go
<-ctx.Done()
return vmcore.Message{}, ctx.Err()
return vm.work.WaitForEvent(ctx)
}
// ======== Issuer Management ========
+14
View File
@@ -253,11 +253,23 @@ func TestVMRevokeCredential(t *testing.T) {
require.False(valid)
}
// withPendingCredential gives a VM one credential to build a block from, which
// a block now needs: an empty one says nothing and still has to be voted on.
func withPendingCredential(t *testing.T, vm *VM) {
t.Helper()
subject, err := vm.CreateIdentity([]byte("subject-key"), nil)
require.NoError(t, err)
_, err = vm.IssueCredential(subject.ID, subject.ID, []string{"test"},
map[string]interface{}{"claim": "value"}, time.Hour)
require.NoError(t, err)
}
func TestVMBuildBlock(t *testing.T) {
require := require.New(t)
vm := setupTestVM(t)
defer vm.Shutdown(context.Background())
withPendingCredential(t, vm)
// Build a block
blk, err := vm.BuildBlock(context.Background())
@@ -276,6 +288,7 @@ func TestVMParseBlock(t *testing.T) {
vm := setupTestVM(t)
defer vm.Shutdown(context.Background())
withPendingCredential(t, vm)
blk, err := vm.BuildBlock(context.Background())
require.NoError(err)
@@ -292,6 +305,7 @@ func TestBlockVerifyAcceptReject(t *testing.T) {
vm := setupTestVM(t)
defer vm.Shutdown(context.Background())
withPendingCredential(t, vm)
blk, err := vm.BuildBlock(context.Background())
require.NoError(err)