DChain single-node blockchain + React Native messenger client. Core: - PBFT consensus with multi-sig validator admission + equivocation slashing - BadgerDB + schema migration scaffold (CurrentSchemaVersion=0) - libp2p gossipsub (tx/v1, blocks/v1, relay/v1, version/v1) - Native Go contracts (username_registry) alongside WASM (wazero) - WebSocket gateway with topic-based fanout + Ed25519-nonce auth - Relay mailbox with NaCl envelope encryption (X25519 + Ed25519) - Prometheus /metrics, per-IP rate limit, body-size cap Deployment: - Single-node compose (deploy/single/) with Caddy TLS + optional Prometheus - 3-node dev compose (docker-compose.yml) with mocked internet topology - 3-validator prod compose (deploy/prod/) for federation - Auto-update from Gitea via /api/update-check + systemd timer - Build-time version injection (ldflags → node --version) - UI / Swagger toggle flags (DCHAIN_DISABLE_UI, DCHAIN_DISABLE_SWAGGER) Client (client-app/): - Expo / React Native / NativeWind - E2E NaCl encryption, typing indicator, contact requests - Auto-discovery of canonical contracts, chain_id aware, WS reconnect on node switch Documentation: - README.md, CHANGELOG.md, CONTEXT.md - deploy/single/README.md with 6 operator scenarios - deploy/UPDATE_STRATEGY.md with 4-layer forward-compat design - docs/contracts/*.md per contract
685 lines
21 KiB
Go
685 lines
21 KiB
Go
package vm
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/binary"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
|
|
"go-blockchain/blockchain"
|
|
)
|
|
|
|
// ── mock host env ─────────────────────────────────────────────────────────────
|
|
|
|
type mockEnv struct {
|
|
state map[string][]byte
|
|
balances map[string]uint64
|
|
caller string
|
|
blockHeight uint64
|
|
logs []string
|
|
}
|
|
|
|
func newMockEnv(caller string) *mockEnv {
|
|
return &mockEnv{
|
|
state: make(map[string][]byte),
|
|
balances: make(map[string]uint64),
|
|
caller: caller,
|
|
}
|
|
}
|
|
|
|
func (m *mockEnv) GetState(key []byte) ([]byte, error) {
|
|
v := m.state[string(key)]
|
|
return v, nil
|
|
}
|
|
func (m *mockEnv) SetState(key, value []byte) error {
|
|
m.state[string(key)] = append([]byte(nil), value...)
|
|
return nil
|
|
}
|
|
func (m *mockEnv) GetBalance(pub string) (uint64, error) { return m.balances[pub], nil }
|
|
func (m *mockEnv) Transfer(from, to string, amount uint64) error {
|
|
if m.balances[from] < amount {
|
|
return errors.New("insufficient balance")
|
|
}
|
|
m.balances[from] -= amount
|
|
m.balances[to] += amount
|
|
return nil
|
|
}
|
|
func (m *mockEnv) GetCaller() string { return m.caller }
|
|
func (m *mockEnv) GetBlockHeight() uint64 { return m.blockHeight }
|
|
func (m *mockEnv) GetContractTreasury() string {
|
|
return "0000000000000000000000000000000000000000000000000000000000000000"
|
|
}
|
|
func (m *mockEnv) Log(msg string) { m.logs = append(m.logs, msg) }
|
|
func (m *mockEnv) CallContract(contractID, method string, argsJSON []byte, gasLimit uint64) (uint64, error) {
|
|
return 0, fmt.Errorf("CallContract not supported in test mock")
|
|
}
|
|
|
|
// counterWASM loads counter.wasm relative to the test file.
|
|
func counterWASM(t *testing.T) []byte {
|
|
t.Helper()
|
|
data, err := os.ReadFile("../contracts/counter/counter.wasm")
|
|
if err != nil {
|
|
t.Fatalf("load counter.wasm: %v", err)
|
|
}
|
|
return data
|
|
}
|
|
|
|
// readU64State reads an 8-byte big-endian uint64 from env state.
|
|
func readU64State(env *mockEnv, key string) uint64 {
|
|
v := env.state[key]
|
|
if len(v) < 8 {
|
|
return 0
|
|
}
|
|
return binary.BigEndian.Uint64(v[:8])
|
|
}
|
|
|
|
// ── unit tests ────────────────────────────────────────────────────────────────
|
|
|
|
func TestValidate_ValidWASM(t *testing.T) {
|
|
ctx := context.Background()
|
|
v := NewVM(ctx)
|
|
defer v.Close(ctx)
|
|
|
|
wasmBytes := counterWASM(t)
|
|
if err := v.Validate(ctx, wasmBytes); err != nil {
|
|
t.Fatalf("Validate valid WASM: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidate_InvalidBytes(t *testing.T) {
|
|
ctx := context.Background()
|
|
v := NewVM(ctx)
|
|
defer v.Close(ctx)
|
|
|
|
if err := v.Validate(ctx, []byte("not a wasm file")); err == nil {
|
|
t.Fatal("expected error for invalid WASM bytes")
|
|
}
|
|
}
|
|
|
|
func TestValidate_EmptyBytes(t *testing.T) {
|
|
ctx := context.Background()
|
|
v := NewVM(ctx)
|
|
defer v.Close(ctx)
|
|
|
|
if err := v.Validate(ctx, []byte{}); err == nil {
|
|
t.Fatal("expected error for empty bytes")
|
|
}
|
|
}
|
|
|
|
func TestCall_UnknownMethod(t *testing.T) {
|
|
ctx := context.Background()
|
|
v := NewVM(ctx)
|
|
defer v.Close(ctx)
|
|
|
|
wasmBytes := counterWASM(t)
|
|
env := newMockEnv("caller1")
|
|
_, err := v.Call(ctx, "test-id", wasmBytes, "nonexistent", nil, 100_000, env)
|
|
if err == nil {
|
|
t.Fatal("expected error for unknown method")
|
|
}
|
|
if !errors.Is(err, blockchain.ErrTxFailed) {
|
|
t.Fatalf("expected ErrTxFailed, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCall_GasExhausted(t *testing.T) {
|
|
ctx := context.Background()
|
|
v := NewVM(ctx)
|
|
defer v.Close(ctx)
|
|
|
|
wasmBytes := counterWASM(t)
|
|
env := newMockEnv("caller1")
|
|
// Gas limit of 1 unit is far too low for any real work.
|
|
_, err := v.Call(ctx, "test-id", wasmBytes, "increment", nil, 1, env)
|
|
if err == nil {
|
|
t.Fatal("expected ErrOutOfGas")
|
|
}
|
|
if !errors.Is(err, ErrOutOfGas) {
|
|
t.Fatalf("expected ErrOutOfGas, got: %v", err)
|
|
}
|
|
if !errors.Is(err, blockchain.ErrTxFailed) {
|
|
t.Fatalf("ErrOutOfGas must wrap ErrTxFailed, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// ── integration tests (counter contract) ─────────────────────────────────────
|
|
|
|
func TestCounter_Increment(t *testing.T) {
|
|
ctx := context.Background()
|
|
v := NewVM(ctx)
|
|
defer v.Close(ctx)
|
|
|
|
wasmBytes := counterWASM(t)
|
|
env := newMockEnv("alice")
|
|
|
|
for i := 1; i <= 3; i++ {
|
|
gasUsed, err := v.Call(ctx, "ctr", wasmBytes, "increment", nil, 1_000_000, env)
|
|
if err != nil {
|
|
t.Fatalf("increment %d: %v", i, err)
|
|
}
|
|
if gasUsed == 0 {
|
|
t.Errorf("increment %d: expected gas > 0", i)
|
|
}
|
|
got := readU64State(env, "counter")
|
|
if got != uint64(i) {
|
|
t.Errorf("after increment %d: counter=%d, want %d", i, got, i)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCounter_Get(t *testing.T) {
|
|
ctx := context.Background()
|
|
v := NewVM(ctx)
|
|
defer v.Close(ctx)
|
|
|
|
wasmBytes := counterWASM(t)
|
|
env := newMockEnv("alice")
|
|
|
|
// Increment twice then call get.
|
|
for i := 0; i < 2; i++ {
|
|
if _, err := v.Call(ctx, "ctr", wasmBytes, "increment", nil, 1_000_000, env); err != nil {
|
|
t.Fatalf("increment: %v", err)
|
|
}
|
|
}
|
|
if _, err := v.Call(ctx, "ctr", wasmBytes, "get", nil, 1_000_000, env); err != nil {
|
|
t.Fatalf("get: %v", err)
|
|
}
|
|
// get logs "get called"
|
|
logged := false
|
|
for _, l := range env.logs {
|
|
if l == "get called" {
|
|
logged = true
|
|
}
|
|
}
|
|
if !logged {
|
|
t.Error("expected 'get called' in logs")
|
|
}
|
|
}
|
|
|
|
func TestCounter_Reset_AuthorizedOwner(t *testing.T) {
|
|
ctx := context.Background()
|
|
v := NewVM(ctx)
|
|
defer v.Close(ctx)
|
|
|
|
wasmBytes := counterWASM(t)
|
|
env := newMockEnv("alice")
|
|
|
|
// Increment 5 times.
|
|
for i := 0; i < 5; i++ {
|
|
if _, err := v.Call(ctx, "ctr", wasmBytes, "increment", nil, 1_000_000, env); err != nil {
|
|
t.Fatalf("increment: %v", err)
|
|
}
|
|
}
|
|
if got := readU64State(env, "counter"); got != 5 {
|
|
t.Fatalf("before reset: counter=%d, want 5", got)
|
|
}
|
|
|
|
// First reset — alice becomes owner.
|
|
if _, err := v.Call(ctx, "ctr", wasmBytes, "reset", nil, 1_000_000, env); err != nil {
|
|
t.Fatalf("reset (owner set): %v", err)
|
|
}
|
|
if got := readU64State(env, "counter"); got != 0 {
|
|
t.Fatalf("after reset: counter=%d, want 0", got)
|
|
}
|
|
|
|
// Increment again then reset again (alice is owner).
|
|
for i := 0; i < 3; i++ {
|
|
if _, err := v.Call(ctx, "ctr", wasmBytes, "increment", nil, 1_000_000, env); err != nil {
|
|
t.Fatalf("increment: %v", err)
|
|
}
|
|
}
|
|
if _, err := v.Call(ctx, "ctr", wasmBytes, "reset", nil, 1_000_000, env); err != nil {
|
|
t.Fatalf("reset (owner confirmed): %v", err)
|
|
}
|
|
if got := readU64State(env, "counter"); got != 0 {
|
|
t.Fatalf("second reset: counter=%d, want 0", got)
|
|
}
|
|
}
|
|
|
|
func TestCounter_Reset_UnauthorizedRejected(t *testing.T) {
|
|
ctx := context.Background()
|
|
v := NewVM(ctx)
|
|
defer v.Close(ctx)
|
|
|
|
wasmBytes := counterWASM(t)
|
|
envAlice := newMockEnv("alice")
|
|
|
|
// Alice increments and performs first reset (sets herself as owner).
|
|
if _, err := v.Call(ctx, "ctr", wasmBytes, "increment", nil, 1_000_000, envAlice); err != nil {
|
|
t.Fatalf("increment: %v", err)
|
|
}
|
|
if _, err := v.Call(ctx, "ctr", wasmBytes, "reset", nil, 1_000_000, envAlice); err != nil {
|
|
t.Fatalf("reset (set owner): %v", err)
|
|
}
|
|
|
|
// Bob tries to reset using the same state but with his caller ID.
|
|
// We simulate this by setting the owner in bob's env to alice's value.
|
|
envBob := newMockEnv("bob")
|
|
envBob.state = envAlice.state // shared state (bob sees alice as owner)
|
|
|
|
// Bob increments so counter > 0.
|
|
if _, err := v.Call(ctx, "ctr", wasmBytes, "increment", nil, 1_000_000, envBob); err != nil {
|
|
t.Fatalf("bob increment: %v", err)
|
|
}
|
|
counterBefore := readU64State(envBob, "counter")
|
|
|
|
// Bob tries reset — should be rejected (logs "unauthorized").
|
|
if _, err := v.Call(ctx, "ctr", wasmBytes, "reset", nil, 1_000_000, envBob); err != nil {
|
|
t.Fatalf("bob reset call error: %v", err)
|
|
}
|
|
// Counter should not be 0.
|
|
if got := readU64State(envBob, "counter"); got == 0 {
|
|
t.Errorf("bob reset succeeded (counter=%d), expected it to be rejected (was %d)", got, counterBefore)
|
|
}
|
|
// Should have logged "unauthorized".
|
|
logged := false
|
|
for _, l := range envBob.logs {
|
|
if l == "unauthorized" {
|
|
logged = true
|
|
}
|
|
}
|
|
if !logged {
|
|
t.Error("expected 'unauthorized' log when bob tries to reset")
|
|
}
|
|
}
|
|
|
|
func TestCounter_GasReturned(t *testing.T) {
|
|
ctx := context.Background()
|
|
v := NewVM(ctx)
|
|
defer v.Close(ctx)
|
|
|
|
wasmBytes := counterWASM(t)
|
|
env := newMockEnv("alice")
|
|
|
|
gasUsed, err := v.Call(ctx, "ctr", wasmBytes, "increment", nil, 1_000_000, env)
|
|
if err != nil {
|
|
t.Fatalf("increment: %v", err)
|
|
}
|
|
if gasUsed == 0 || gasUsed >= 1_000_000 {
|
|
t.Errorf("unexpected gas: %d", gasUsed)
|
|
}
|
|
t.Logf("increment gasUsed=%d", gasUsed)
|
|
}
|
|
|
|
func TestABI_Validate(t *testing.T) {
|
|
a, err := ParseABI(`{"methods":[{"name":"increment","args":[]},{"name":"get","args":[]},{"name":"reset","args":[]}]}`)
|
|
if err != nil {
|
|
t.Fatalf("ParseABI: %v", err)
|
|
}
|
|
if !a.HasMethod("increment") {
|
|
t.Error("expected HasMethod(increment)")
|
|
}
|
|
if a.HasMethod("nonexistent") {
|
|
t.Error("unexpected HasMethod(nonexistent)")
|
|
}
|
|
if err := a.Validate("increment", nil); err != nil {
|
|
t.Errorf("Validate increment: %v", err)
|
|
}
|
|
if err := a.Validate("unknown", nil); err == nil {
|
|
t.Error("expected error for unknown method")
|
|
}
|
|
}
|
|
|
|
func TestABI_NamedArgs(t *testing.T) {
|
|
a, err := ParseABI(`{"methods":[
|
|
{"name":"register","args":[{"name":"name","type":"string"}]},
|
|
{"name":"transfer","args":[{"name":"name","type":"string"},{"name":"new_owner","type":"string"}]}
|
|
]}`)
|
|
if err != nil {
|
|
t.Fatalf("ParseABI: %v", err)
|
|
}
|
|
if !a.HasMethod("register") {
|
|
t.Error("expected HasMethod(register)")
|
|
}
|
|
// register expects 1 arg
|
|
if err := a.Validate("register", []byte(`["alice"]`)); err != nil {
|
|
t.Errorf("Validate register 1 arg: %v", err)
|
|
}
|
|
if err := a.Validate("register", []byte(`["alice","extra"]`)); err == nil {
|
|
t.Error("expected error for too many args")
|
|
}
|
|
// transfer expects 2 args
|
|
if err := a.Validate("transfer", []byte(`["alice","bob_pubkey"]`)); err != nil {
|
|
t.Errorf("Validate transfer 2 args: %v", err)
|
|
}
|
|
// Inspect arg metadata
|
|
if a.Methods[0].Args[0].Name != "name" {
|
|
t.Errorf("arg name: want 'name', got %q", a.Methods[0].Args[0].Name)
|
|
}
|
|
if a.Methods[0].Args[0].Type != "string" {
|
|
t.Errorf("arg type: want 'string', got %q", a.Methods[0].Args[0].Type)
|
|
}
|
|
}
|
|
|
|
// ── name registry contract tests ──────────────────────────────────────────────
|
|
|
|
func nameRegistryWASM(t *testing.T) []byte {
|
|
t.Helper()
|
|
data, err := os.ReadFile("../contracts/name_registry/name_registry.wasm")
|
|
if err != nil {
|
|
t.Fatalf("load name_registry.wasm: %v", err)
|
|
}
|
|
return data
|
|
}
|
|
|
|
func TestNameRegistry_Register(t *testing.T) {
|
|
ctx := context.Background()
|
|
v := NewVM(ctx)
|
|
defer v.Close(ctx)
|
|
|
|
wasm := nameRegistryWASM(t)
|
|
env := newMockEnv("alice_pubkey_hex")
|
|
|
|
_, err := v.Call(ctx, "reg", wasm, "register", []byte(`["alice"]`), 1_000_000, env)
|
|
if err != nil {
|
|
t.Fatalf("register: %v", err)
|
|
}
|
|
// State key "alice" should now contain the caller pubkey bytes.
|
|
val := env.state["alice"]
|
|
if string(val) != "alice_pubkey_hex" {
|
|
t.Errorf("state[alice] = %q, want %q", val, "alice_pubkey_hex")
|
|
}
|
|
// Should have logged something containing "registered"
|
|
if len(env.logs) == 0 || !strings.Contains(env.logs[len(env.logs)-1], "registered") {
|
|
t.Errorf("expected last log 'registered', got %v", env.logs)
|
|
}
|
|
}
|
|
|
|
func TestNameRegistry_NameTaken(t *testing.T) {
|
|
ctx := context.Background()
|
|
v := NewVM(ctx)
|
|
defer v.Close(ctx)
|
|
|
|
wasm := nameRegistryWASM(t)
|
|
env := newMockEnv("alice_pubkey_hex")
|
|
|
|
// First registration succeeds.
|
|
if _, err := v.Call(ctx, "reg", wasm, "register", []byte(`["alice"]`), 1_000_000, env); err != nil {
|
|
t.Fatalf("first register: %v", err)
|
|
}
|
|
// Second registration by a different caller should log "name taken".
|
|
env2 := newMockEnv("bob_pubkey_hex")
|
|
env2.state = env.state // shared state
|
|
logsBefore := len(env2.logs)
|
|
if _, err := v.Call(ctx, "reg", wasm, "register", []byte(`["alice"]`), 1_000_000, env2); err != nil {
|
|
t.Fatalf("second register: %v", err)
|
|
}
|
|
found := false
|
|
for _, l := range env2.logs[logsBefore:] {
|
|
if strings.Contains(l, "name taken") {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("expected 'name taken' log, got %v", env2.logs)
|
|
}
|
|
// Owner should still be alice.
|
|
if string(env.state["alice"]) != "alice_pubkey_hex" {
|
|
t.Error("owner changed unexpectedly")
|
|
}
|
|
}
|
|
|
|
func TestNameRegistry_Resolve(t *testing.T) {
|
|
ctx := context.Background()
|
|
v := NewVM(ctx)
|
|
defer v.Close(ctx)
|
|
|
|
wasm := nameRegistryWASM(t)
|
|
env := newMockEnv("alice_pubkey_hex")
|
|
|
|
if _, err := v.Call(ctx, "reg", wasm, "register", []byte(`["alice"]`), 1_000_000, env); err != nil {
|
|
t.Fatalf("register: %v", err)
|
|
}
|
|
env.logs = nil
|
|
if _, err := v.Call(ctx, "reg", wasm, "resolve", []byte(`["alice"]`), 1_000_000, env); err != nil {
|
|
t.Fatalf("resolve: %v", err)
|
|
}
|
|
// Should log the owner pubkey (verbose: "owner: alice_pubkey_hex").
|
|
if len(env.logs) == 0 || !strings.Contains(env.logs[0], "alice_pubkey_hex") {
|
|
t.Errorf("resolve logged %v, want something containing alice_pubkey_hex", env.logs)
|
|
}
|
|
|
|
// Resolve unknown name logs "not found".
|
|
env.logs = nil
|
|
if _, err := v.Call(ctx, "reg", wasm, "resolve", []byte(`["unknown"]`), 1_000_000, env); err != nil {
|
|
t.Fatalf("resolve unknown: %v", err)
|
|
}
|
|
if len(env.logs) == 0 || !strings.Contains(env.logs[0], "not found") {
|
|
t.Errorf("expected 'not found', got %v", env.logs)
|
|
}
|
|
}
|
|
|
|
func TestNameRegistry_Transfer(t *testing.T) {
|
|
ctx := context.Background()
|
|
v := NewVM(ctx)
|
|
defer v.Close(ctx)
|
|
|
|
wasm := nameRegistryWASM(t)
|
|
envAlice := newMockEnv("alice_pubkey_hex")
|
|
|
|
if _, err := v.Call(ctx, "reg", wasm, "register", []byte(`["alice"]`), 1_000_000, envAlice); err != nil {
|
|
t.Fatalf("register: %v", err)
|
|
}
|
|
// Alice transfers "alice" to bob.
|
|
if _, err := v.Call(ctx, "reg", wasm, "transfer",
|
|
[]byte(`["alice","bob_pubkey_hex"]`), 1_000_000, envAlice); err != nil {
|
|
t.Fatalf("transfer: %v", err)
|
|
}
|
|
if string(envAlice.state["alice"]) != "bob_pubkey_hex" {
|
|
t.Errorf("state[alice] = %q, want bob_pubkey_hex", envAlice.state["alice"])
|
|
}
|
|
lastLog := envAlice.logs[len(envAlice.logs)-1]
|
|
if !strings.Contains(lastLog, "transferred") {
|
|
t.Errorf("expected 'transferred' log, got %q", lastLog)
|
|
}
|
|
}
|
|
|
|
func TestNameRegistry_Transfer_Unauthorized(t *testing.T) {
|
|
ctx := context.Background()
|
|
v := NewVM(ctx)
|
|
defer v.Close(ctx)
|
|
|
|
wasm := nameRegistryWASM(t)
|
|
envAlice := newMockEnv("alice_pubkey_hex")
|
|
|
|
if _, err := v.Call(ctx, "reg", wasm, "register", []byte(`["alice"]`), 1_000_000, envAlice); err != nil {
|
|
t.Fatalf("register: %v", err)
|
|
}
|
|
|
|
// Bob tries to transfer alice's name.
|
|
envBob := newMockEnv("bob_pubkey_hex")
|
|
envBob.state = envAlice.state
|
|
if _, err := v.Call(ctx, "reg", wasm, "transfer",
|
|
[]byte(`["alice","bob_pubkey_hex"]`), 1_000_000, envBob); err != nil {
|
|
t.Fatalf("transfer call: %v", err)
|
|
}
|
|
// Owner should still be alice.
|
|
if string(envBob.state["alice"]) != "alice_pubkey_hex" {
|
|
t.Errorf("unauthorized transfer succeeded, state = %q", envBob.state["alice"])
|
|
}
|
|
found := false
|
|
for _, l := range envBob.logs {
|
|
if strings.Contains(l, "unauthorized") {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("expected 'unauthorized' log, got %v", envBob.logs)
|
|
}
|
|
}
|
|
|
|
func TestNameRegistry_Release(t *testing.T) {
|
|
ctx := context.Background()
|
|
v := NewVM(ctx)
|
|
defer v.Close(ctx)
|
|
|
|
wasm := nameRegistryWASM(t)
|
|
env := newMockEnv("alice_pubkey_hex")
|
|
|
|
if _, err := v.Call(ctx, "reg", wasm, "register", []byte(`["alice"]`), 1_000_000, env); err != nil {
|
|
t.Fatalf("register: %v", err)
|
|
}
|
|
if _, err := v.Call(ctx, "reg", wasm, "release", []byte(`["alice"]`), 1_000_000, env); err != nil {
|
|
t.Fatalf("release: %v", err)
|
|
}
|
|
// State should now be empty (released).
|
|
val := env.state["alice"]
|
|
if len(val) > 0 {
|
|
t.Errorf("after release state[alice] = %q, want empty", val)
|
|
}
|
|
lastLog := env.logs[len(env.logs)-1]
|
|
if !strings.Contains(lastLog, "released") {
|
|
t.Errorf("expected 'released' log, got %q", lastLog)
|
|
}
|
|
|
|
// After release, anyone can re-register.
|
|
env2 := newMockEnv("charlie_pubkey_hex")
|
|
env2.state = env.state
|
|
if _, err := v.Call(ctx, "reg", wasm, "register", []byte(`["alice"]`), 1_000_000, env2); err != nil {
|
|
t.Fatalf("re-register after release: %v", err)
|
|
}
|
|
if string(env2.state["alice"]) != "charlie_pubkey_hex" {
|
|
t.Errorf("re-register: state[alice] = %q, want charlie_pubkey_hex", env2.state["alice"])
|
|
}
|
|
}
|
|
|
|
// ── Phase 9: instruction-level gas metering ───────────────────────────────────
|
|
|
|
// infiniteLoopWASM is a hand-encoded WASM module that exports one function,
|
|
// "loop_forever", which contains an unconditional infinite loop.
|
|
// The Instrument function must inject gas_tick so the loop is terminated.
|
|
//
|
|
// WAT equivalent:
|
|
//
|
|
// (module
|
|
// (func (export "loop_forever")
|
|
// (loop $L (br $L))
|
|
// )
|
|
// )
|
|
var infiniteLoopWASM = []byte{
|
|
// magic + version
|
|
0x00, 0x61, 0x73, 0x6D, 0x01, 0x00, 0x00, 0x00,
|
|
// type section: 1 type — () → ()
|
|
0x01, 0x04, 0x01, 0x60, 0x00, 0x00,
|
|
// function section: 1 function using type 0
|
|
0x03, 0x02, 0x01, 0x00,
|
|
// export section: export "loop_forever" as function 0
|
|
0x07, 0x10, 0x01, 0x0C,
|
|
0x6C, 0x6F, 0x6F, 0x70, 0x5F, 0x66, 0x6F, 0x72, 0x65, 0x76, 0x65, 0x72, // "loop_forever"
|
|
0x00, 0x00,
|
|
// code section: 1 entry — body: loop void; br 0; end; end
|
|
0x0A, 0x09, 0x01, 0x07, 0x00,
|
|
0x03, 0x40, // loop void
|
|
0x0C, 0x00, // br 0
|
|
0x0B, // end (loop)
|
|
0x0B, // end (function)
|
|
}
|
|
|
|
// TestInstrument_InfiniteLoop verifies that Instrument succeeds and produces
|
|
// valid WASM for a module containing an infinite loop.
|
|
func TestInstrument_InfiniteLoop(t *testing.T) {
|
|
instrumented, err := Instrument(infiniteLoopWASM)
|
|
if err != nil {
|
|
t.Fatalf("Instrument: %v", err)
|
|
}
|
|
if len(instrumented) <= len(infiniteLoopWASM) {
|
|
t.Errorf("instrumented binary (%d B) not larger than original (%d B)",
|
|
len(instrumented), len(infiniteLoopWASM))
|
|
}
|
|
// Must still be valid WASM.
|
|
ctx := context.Background()
|
|
v := NewVM(ctx)
|
|
defer v.Close(ctx)
|
|
if err := v.Validate(ctx, instrumented); err != nil {
|
|
t.Fatalf("Validate instrumented: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestInstrument_Idempotent verifies that instrumenting an already-instrumented
|
|
// binary is a no-op (returns identical bytes).
|
|
func TestInstrument_Idempotent(t *testing.T) {
|
|
once, err := Instrument(infiniteLoopWASM)
|
|
if err != nil {
|
|
t.Fatalf("first Instrument: %v", err)
|
|
}
|
|
twice, err := Instrument(once)
|
|
if err != nil {
|
|
t.Fatalf("second Instrument: %v", err)
|
|
}
|
|
if !bytes.Equal(once, twice) {
|
|
t.Error("second instrumentation changed the binary — not idempotent")
|
|
}
|
|
}
|
|
|
|
// TestInfiniteLoop_TrappedByGas verifies that an infinite loop is terminated
|
|
// when the gas budget is exhausted.
|
|
func TestInfiniteLoop_TrappedByGas(t *testing.T) {
|
|
ctx := context.Background()
|
|
v := NewVM(ctx)
|
|
defer v.Close(ctx)
|
|
|
|
env := newMockEnv("alice")
|
|
_, err := v.Call(ctx, "inf-loop", infiniteLoopWASM, "loop_forever", nil, 10_000, env)
|
|
if err == nil {
|
|
t.Fatal("expected error from infinite loop — should have been trapped by gas")
|
|
}
|
|
if !errors.Is(err, ErrOutOfGas) {
|
|
t.Fatalf("expected ErrOutOfGas, got: %v", err)
|
|
}
|
|
if !errors.Is(err, blockchain.ErrTxFailed) {
|
|
t.Fatalf("ErrOutOfGas must wrap ErrTxFailed, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestNameRegistry_GasIncludesLoopCost verifies that calling a contract method
|
|
// that exercises a loop (bytes_equal in name_registry) produces a non-zero
|
|
// gasUsed that is higher than the function-call minimum.
|
|
func TestNameRegistry_GasIncludesLoopCost(t *testing.T) {
|
|
ctx := context.Background()
|
|
v := NewVM(ctx)
|
|
defer v.Close(ctx)
|
|
|
|
wasm := nameRegistryWASM(t)
|
|
env := newMockEnv("alice_pubkey_hex")
|
|
|
|
gasUsed, err := v.Call(ctx, "reg-gas", wasm, "register", []byte(`["alice"]`), 1_000_000, env)
|
|
if err != nil {
|
|
t.Fatalf("register: %v", err)
|
|
}
|
|
// The name-registry register function calls bytes_equal in a loop.
|
|
// With loop-header metering, gas > just function-call overhead.
|
|
if gasUsed == 0 {
|
|
t.Error("gasUsed == 0, expected > 0")
|
|
}
|
|
t.Logf("name_registry.register gasUsed = %d", gasUsed)
|
|
}
|
|
|
|
// TestInstrument_CounterWASM verifies that the counter contract (no loops)
|
|
// is instrumented without error and remains functionally identical.
|
|
func TestInstrument_CounterWASM(t *testing.T) {
|
|
original := counterWASM(t)
|
|
instrumented, err := Instrument(original)
|
|
if err != nil {
|
|
t.Fatalf("Instrument counter: %v", err)
|
|
}
|
|
// Counter WASM has no loops — instrumented size may equal original.
|
|
ctx := context.Background()
|
|
v := NewVM(ctx)
|
|
defer v.Close(ctx)
|
|
if err := v.Validate(ctx, instrumented); err != nil {
|
|
t.Fatalf("Validate instrumented counter: %v", err)
|
|
}
|
|
// Function still works after instrumentation.
|
|
env := newMockEnv("alice")
|
|
if _, err := v.Call(ctx, "ctr-instr", instrumented, "increment", nil, 1_000_000, env); err != nil {
|
|
t.Fatalf("increment on instrumented counter: %v", err)
|
|
}
|
|
if readU64State(env, "counter") != 1 {
|
|
t.Error("counter not incremented after instrumentation")
|
|
}
|
|
}
|