From 581ce804b75c4070c6508b4fdef87b34a6bcb591 Mon Sep 17 00:00:00 2001 From: Sanka Date: Fri, 26 Jun 2026 17:07:13 +0200 Subject: [PATCH 1/2] test: add first Go unit suite for primitives, config, abi, wallet The repository had no Go tests. This adds a focused, network-free suite over the pure/deterministic packages, runnable with `go test ./...`: - primitives: All/IsValid plus Registry invariants (build/ABI/target per kind) - config: TOML Load/Save round-trip, local-node default injection, primitive inference from legacy sections, missing-file error, HasPrimitive - abi: ParseContract over annotated Rust (params/flag/return), invalid-type and name-mismatch errors, IsValidType/GetTypeInfo - wallet: ValidateSeed/ValidateAddress, SeedToAddress known-answer vector (masterpassphrase) + determinism, GenerateWallet round-trip, getAlgorithm Tests are table-driven and use t.TempDir(). One assertion documents a subtle parser behaviour surfaced while writing it: @flag is positional and applies to the @param annotations that follow it. 21 test functions, all green; go vet and gofmt clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/abi/parser_test.go | 140 ++++++++++++++++++++++++++++ pkg/config/loader_test.go | 149 ++++++++++++++++++++++++++++++ pkg/primitives/primitives_test.go | 77 +++++++++++++++ pkg/wallet/xrpl_test.go | 118 +++++++++++++++++++++++ 4 files changed, 484 insertions(+) create mode 100644 pkg/abi/parser_test.go create mode 100644 pkg/config/loader_test.go create mode 100644 pkg/primitives/primitives_test.go create mode 100644 pkg/wallet/xrpl_test.go diff --git a/pkg/abi/parser_test.go b/pkg/abi/parser_test.go new file mode 100644 index 0000000..4afce1e --- /dev/null +++ b/pkg/abi/parser_test.go @@ -0,0 +1,140 @@ +package abi + +import ( + "os" + "path/filepath" + "testing" +) + +// writeRust creates a temp dir containing a single lib.rs with the given body +// and returns the dir (suitable for NewParser). +func writeRust(t *testing.T, body string) string { + t.Helper() + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "lib.rs"), []byte(body), 0644); err != nil { + t.Fatalf("failed to write lib.rs: %v", err) + } + return dir +} + +func TestParseContract_FunctionWithParamsFlagReturn(t *testing.T) { + // @flag is positional: it applies to the params that follow it. Here + // `amount` precedes the @flag (so flag 0) and `dest` follows it (flag 1). + dir := writeRust(t, ` +/// @xrpl-function transfer +/// @param amount UINT64 - amount in drops +/// @flag 1 +/// @param dest ACCOUNT - recipient +/// @return UINT32 - status code +pub fn transfer(amount: u64) -> i32 { + 0 +} +`) + + parsed, err := NewParser(dir).ParseContract("demo") + if err != nil { + t.Fatalf("ParseContract() error: %v", err) + } + + if parsed.ContractName != "demo" { + t.Errorf("ContractName = %q, want demo", parsed.ContractName) + } + if len(parsed.Functions) != 1 { + t.Fatalf("got %d functions, want 1: %+v", len(parsed.Functions), parsed.Functions) + } + + fn := parsed.Functions[0] + if fn.Name != "transfer" { + t.Errorf("function name = %q, want transfer", fn.Name) + } + if len(fn.Parameters) != 2 { + t.Fatalf("got %d params, want 2: %+v", len(fn.Parameters), fn.Parameters) + } + if fn.Parameters[0].Name != "amount" || fn.Parameters[0].Type != "UINT64" { + t.Errorf("param[0] = %+v, want amount/UINT64", fn.Parameters[0]) + } + if fn.Parameters[0].Description != "amount in drops" { + t.Errorf("param[0].Description = %q", fn.Parameters[0].Description) + } + if fn.Parameters[1].Name != "dest" || fn.Parameters[1].Type != "ACCOUNT" { + t.Errorf("param[1] = %+v, want dest/ACCOUNT", fn.Parameters[1]) + } + // Positional @flag: amount (before @flag) = 0, dest (after @flag) = 1. + if fn.Parameters[0].Flag != 0 { + t.Errorf("param[0].Flag = %d, want 0", fn.Parameters[0].Flag) + } + if fn.Parameters[1].Flag != 1 { + t.Errorf("param[1].Flag = %d, want 1", fn.Parameters[1].Flag) + } + if fn.Returns == nil || fn.Returns.Type != "UINT32" { + t.Errorf("return = %+v, want UINT32", fn.Returns) + } +} + +func TestParseContract_MultipleFunctions(t *testing.T) { + dir := writeRust(t, ` +/// @xrpl-function hello +pub fn hello() -> i32 { 0 } + +/// @xrpl-function ping +pub fn ping() -> i32 { 0 } +`) + + parsed, err := NewParser(dir).ParseContract("demo") + if err != nil { + t.Fatalf("ParseContract() error: %v", err) + } + if len(parsed.Functions) != 2 { + t.Fatalf("got %d functions, want 2", len(parsed.Functions)) + } +} + +func TestParseContract_InvalidParamTypeErrors(t *testing.T) { + dir := writeRust(t, ` +/// @xrpl-function bad +/// @param x NOTATYPE +pub fn bad() -> i32 { 0 } +`) + + if _, err := NewParser(dir).ParseContract("demo"); err == nil { + t.Fatal("expected error for invalid parameter type, got nil") + } +} + +func TestParseContract_NameMismatchErrors(t *testing.T) { + dir := writeRust(t, ` +/// @xrpl-function foo +pub fn bar() -> i32 { 0 } +`) + + if _, err := NewParser(dir).ParseContract("demo"); err == nil { + t.Fatal("expected error when @xrpl-function name != fn name, got nil") + } +} + +func TestIsValidType(t *testing.T) { + valid := []string{"UINT8", "UINT256", "VL", "ACCOUNT", "AMOUNT", "ISSUE", "CURRENCY", "NUMBER"} + for _, ty := range valid { + if !IsValidType(ty) { + t.Errorf("IsValidType(%q) = false, want true", ty) + } + } + for _, ty := range []string{"", "uint8", "STRING", "u64"} { + if IsValidType(ty) { + t.Errorf("IsValidType(%q) = true, want false", ty) + } + } +} + +func TestGetTypeInfo(t *testing.T) { + info, ok := GetTypeInfo("UINT64") + if !ok { + t.Fatal("GetTypeInfo(UINT64) not found") + } + if info.RustType != "u64" { + t.Errorf("UINT64.RustType = %q, want u64", info.RustType) + } + if _, ok := GetTypeInfo("NOPE"); ok { + t.Error("GetTypeInfo(NOPE) = ok, want not found") + } +} diff --git a/pkg/config/loader_test.go b/pkg/config/loader_test.go new file mode 100644 index 0000000..792403f --- /dev/null +++ b/pkg/config/loader_test.go @@ -0,0 +1,149 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +// writeTOML writes content to a temp bedrock.toml and returns its path. +func writeTOML(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "bedrock.toml") + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatalf("failed to write temp config: %v", err) + } + return path +} + +func TestLoadParsesProjectAndNetworks(t *testing.T) { + path := writeTOML(t, ` +[project] +name = "demo" +version = "0.1.0" + +primitives = ["contract"] + +[networks.local] +url = "ws://localhost:6006" +network_id = 100 +`) + + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load() error: %v", err) + } + + if cfg.Project.Name != "demo" { + t.Errorf("Project.Name = %q, want demo", cfg.Project.Name) + } + net, ok := cfg.Networks["local"] + if !ok { + t.Fatal("networks.local missing") + } + if net.URL != "ws://localhost:6006" { + t.Errorf("local.URL = %q", net.URL) + } + if net.NetworkID != 100 { + t.Errorf("local.NetworkID = %d, want 100", net.NetworkID) + } +} + +// Load fills in local-node defaults when the section is absent. +func TestLoadAppliesLocalNodeDefaults(t *testing.T) { + path := writeTOML(t, ` +[project] +name = "demo" +primitives = ["contract"] +`) + + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load() error: %v", err) + } + + def := DefaultLocalNodeConfig() + if cfg.LocalNode.ConfigDir != def.ConfigDir { + t.Errorf("LocalNode.ConfigDir = %q, want default %q", cfg.LocalNode.ConfigDir, def.ConfigDir) + } + if cfg.LocalNode.DockerImage != def.DockerImage { + t.Errorf("LocalNode.DockerImage = %q, want default %q", cfg.LocalNode.DockerImage, def.DockerImage) + } +} + +// When `primitives` is omitted, Load infers it from the present sections +// (backward compatibility with older bedrock.toml files). +func TestLoadInfersPrimitivesFromSections(t *testing.T) { + path := writeTOML(t, ` +[project] +name = "legacy" + +[contracts.main] +source = "contract/src/lib.rs" +abi = "abi.json" + +[escrows.main] +source = "escrow/src/lib.rs" +`) + + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load() error: %v", err) + } + + if !cfg.HasPrimitive("contract") { + t.Error("expected inferred primitive 'contract' from [contracts.main]") + } + if !cfg.HasPrimitive("escrow") { + t.Error("expected inferred primitive 'escrow' from [escrows.main]") + } + if cfg.HasPrimitive("vault") { + t.Error("did not expect 'vault' to be inferred") + } +} + +func TestLoadMissingFileErrors(t *testing.T) { + if _, err := Load(filepath.Join(t.TempDir(), "does-not-exist.toml")); err == nil { + t.Fatal("Load() of a missing file should return an error") + } +} + +func TestSaveLoadRoundTrip(t *testing.T) { + original := &Config{ + Project: ProjectConfig{Name: "rt", Version: "1.2.3"}, + Primitives: []string{"contract", "vault"}, + Networks: map[string]NetworkConfig{ + "local": {URL: "ws://localhost:6006", NetworkID: 100}, + }, + } + + path := filepath.Join(t.TempDir(), "bedrock.toml") + if err := Save(original, path); err != nil { + t.Fatalf("Save() error: %v", err) + } + + loaded, err := Load(path) + if err != nil { + t.Fatalf("Load() error: %v", err) + } + + if loaded.Project.Name != "rt" || loaded.Project.Version != "1.2.3" { + t.Errorf("project mismatch after round trip: %+v", loaded.Project) + } + if !loaded.HasPrimitive("contract") || !loaded.HasPrimitive("vault") { + t.Errorf("primitives lost in round trip: %v", loaded.Primitives) + } + if loaded.Networks["local"].NetworkID != 100 { + t.Errorf("network_id lost in round trip: %d", loaded.Networks["local"].NetworkID) + } +} + +func TestHasPrimitive(t *testing.T) { + cfg := &Config{Primitives: []string{"contract", "escrow"}} + if !cfg.HasPrimitive("contract") { + t.Error("HasPrimitive(contract) = false, want true") + } + if cfg.HasPrimitive("vault") { + t.Error("HasPrimitive(vault) = true, want false") + } +} diff --git a/pkg/primitives/primitives_test.go b/pkg/primitives/primitives_test.go new file mode 100644 index 0000000..f194deb --- /dev/null +++ b/pkg/primitives/primitives_test.go @@ -0,0 +1,77 @@ +package primitives + +import "testing" + +func TestAll(t *testing.T) { + got := All() + want := []string{Contract, Escrow, Vault} + + if len(got) != len(want) { + t.Fatalf("All() returned %d kinds, want %d", len(got), len(want)) + } + for i, kind := range want { + if got[i] != kind { + t.Errorf("All()[%d] = %q, want %q", i, got[i], kind) + } + } +} + +func TestIsValid(t *testing.T) { + tests := []struct { + kind string + want bool + }{ + {Contract, true}, + {Escrow, true}, + {Vault, true}, + {"", false}, + {"unknown", false}, + {"Contract", false}, // case-sensitive + } + + for _, tt := range tests { + if got := IsValid(tt.kind); got != tt.want { + t.Errorf("IsValid(%q) = %v, want %v", tt.kind, got, tt.want) + } + } +} + +// TestRegistryConsistency guards the invariants other code relies on: +// every advertised kind has a matching entry, and the build/ABI/target +// metadata matches each primitive's documented behaviour. +func TestRegistryConsistency(t *testing.T) { + for _, kind := range All() { + def, ok := Registry[kind] + if !ok { + t.Errorf("All() lists %q but Registry has no entry for it", kind) + continue + } + if def.Kind != kind { + t.Errorf("Registry[%q].Kind = %q, want %q", kind, def.Kind, kind) + } + if !def.NeedsBuild { + t.Errorf("Registry[%q].NeedsBuild = false, want true (all primitives compile to WASM)", kind) + } + if def.WasmTarget == "" || def.RustEdition == "" || def.SourceDir == "" { + t.Errorf("Registry[%q] has empty target/edition/sourceDir: %+v", kind, def) + } + } + + // Contract is the only primitive with an ABI; escrow/vault have none. + if !Registry[Contract].NeedsABI { + t.Error("contract should need an ABI") + } + if Registry[Escrow].NeedsABI || Registry[Vault].NeedsABI { + t.Error("escrow and vault should not need an ABI") + } + + // Targets differ: contract is wasm32-unknown-unknown, the others wasm32v1-none. + if Registry[Contract].WasmTarget != "wasm32-unknown-unknown" { + t.Errorf("contract target = %q, want wasm32-unknown-unknown", Registry[Contract].WasmTarget) + } + for _, kind := range []string{Escrow, Vault} { + if Registry[kind].WasmTarget != "wasm32v1-none" { + t.Errorf("%s target = %q, want wasm32v1-none", kind, Registry[kind].WasmTarget) + } + } +} diff --git a/pkg/wallet/xrpl_test.go b/pkg/wallet/xrpl_test.go new file mode 100644 index 0000000..3075f85 --- /dev/null +++ b/pkg/wallet/xrpl_test.go @@ -0,0 +1,118 @@ +package wallet + +import "testing" + +// The XRPL "masterpassphrase" root account: a well-known deterministic vector. +// This secp256k1 seed always derives to this classic address (it is also the +// genesis account of a standalone rippled node). +const ( + masterSeed = "snoPBrXtMeMyMHUVTgbuqAfg1SUTb" + masterAddress = "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh" +) + +func newWallet(t *testing.T) *XRPLWallet { + t.Helper() + w, err := NewXRPLWallet() + if err != nil { + t.Fatalf("NewXRPLWallet() error: %v", err) + } + return w +} + +func TestValidateSeed(t *testing.T) { + w := newWallet(t) + tests := []struct { + name string + seed string + wantErr bool + }{ + {"valid master seed", masterSeed, false}, + {"empty", "", true}, + {"missing s prefix", "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh", true}, + {"garbage", "snotARealSeed000000000000000", true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := w.ValidateSeed(tt.seed) + if (err != nil) != tt.wantErr { + t.Errorf("ValidateSeed(%q) err = %v, wantErr = %v", tt.seed, err, tt.wantErr) + } + }) + } +} + +// Known-answer test: the master seed must derive to the master address. +func TestSeedToAddress_KnownVector(t *testing.T) { + w := newWallet(t) + addr, err := w.SeedToAddress(masterSeed) + if err != nil { + t.Fatalf("SeedToAddress() error: %v", err) + } + if addr != masterAddress { + t.Errorf("SeedToAddress(%q) = %q, want %q", masterSeed, addr, masterAddress) + } +} + +func TestSeedToAddress_Deterministic(t *testing.T) { + w := newWallet(t) + a1, err := w.SeedToAddress(masterSeed) + if err != nil { + t.Fatalf("SeedToAddress() error: %v", err) + } + a2, _ := w.SeedToAddress(masterSeed) + if a1 != a2 { + t.Errorf("SeedToAddress not deterministic: %q != %q", a1, a2) + } +} + +func TestValidateAddress(t *testing.T) { + w := newWallet(t) + tests := []struct { + name string + address string + wantErr bool + }{ + {"valid master address", masterAddress, false}, + {"empty", "", true}, + {"missing r prefix", "sHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh", true}, + {"too short", "rShort", true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := w.ValidateAddress(tt.address) + if (err != nil) != tt.wantErr { + t.Errorf("ValidateAddress(%q) err = %v, wantErr = %v", tt.address, err, tt.wantErr) + } + }) + } +} + +// A freshly generated wallet's seed must derive back to its own address. +func TestGenerateWallet_RoundTrip(t *testing.T) { + w := newWallet(t) + gen, err := w.GenerateWallet("test") + if err != nil { + t.Fatalf("GenerateWallet() error: %v", err) + } + if err := w.ValidateSeed(gen.Seed); err != nil { + t.Errorf("generated seed failed validation: %v", err) + } + derived, err := w.SeedToAddress(gen.Seed) + if err != nil { + t.Fatalf("SeedToAddress() error: %v", err) + } + if derived != gen.Address { + t.Errorf("round trip mismatch: generated %q but seed derives to %q", gen.Address, derived) + } +} + +func TestGetAlgorithm(t *testing.T) { + for _, algo := range []string{"secp256k1", "ed25519", "", "SECP256K1"} { + if _, err := getAlgorithm(algo); err != nil { + t.Errorf("getAlgorithm(%q) unexpected error: %v", algo, err) + } + } + if _, err := getAlgorithm("rsa"); err == nil { + t.Error("getAlgorithm(rsa) should error") + } +} From 4415b234d6eb6e6dfc935ca97ae76b879edd3f8a Mon Sep 17 00:00:00 2001 From: Hussenet Thomas <113238884+LeJamon@users.noreply.github.com> Date: Mon, 29 Jun 2026 13:53:14 +0200 Subject: [PATCH 2/2] fix: place primitives at TOML root so cfg.Primitives loads `primitives` is a root-level key in bedrock.toml (Config.Primitives is a top-level field), but generateConfig and every doc example emitted it under [project], where TOML parses it as project.primitives and Load silently drops it. cfg.Primitives was only repopulated by the section-inference fallback, so the explicit list users edit had no effect. Move the key above [project] in the scaffolder and all examples, and assert root-level loading in the config test (it previously nested the key under [project] and never checked it loaded). --- CLAUDE.md | 9 ++++++--- docs/guide/smart-escrows.md | 3 ++- docs/guide/smart-vaults.md | 3 ++- internal/cli/init.go | 19 ++++++++++++------- llms.txt | 9 ++++++--- pkg/config/loader_test.go | 12 +++++++++--- 6 files changed, 37 insertions(+), 18 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e77e130..0c91d5f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -376,11 +376,12 @@ pub extern "C" fn on_withdraw() -> i32 { ### Contract Project ```toml +primitives = ["contract"] + [project] name = "my-contract" version = "0.1.0" authors = ["Your Name"] -primitives = ["contract"] [build] source = "contract/src/lib.rs" @@ -413,11 +414,12 @@ keystore = ".wallets/keystore.json" ### Escrow Project ```toml +primitives = ["escrow"] + [project] name = "my-escrow" version = "0.1.0" authors = ["Your Name"] -primitives = ["escrow"] [escrows.main] source = "escrow/src/lib.rs" @@ -445,11 +447,12 @@ keystore = ".wallets/keystore.json" ### Vault Project ```toml +primitives = ["vault"] + [project] name = "my-vault" version = "0.1.0" authors = ["Your Name"] -primitives = ["vault"] [vaults.main] source = "vault/src/lib.rs" diff --git a/docs/guide/smart-escrows.md b/docs/guide/smart-escrows.md index 3b27329..d214e40 100644 --- a/docs/guide/smart-escrows.md +++ b/docs/guide/smart-escrows.md @@ -273,10 +273,11 @@ bedrock init my-escrow --primitives escrow --template escrow-oracle Escrow projects use this `bedrock.toml` structure: ```toml +primitives = ["escrow"] + [project] name = "my-escrow" version = "0.1.0" -primitives = ["escrow"] [escrows.main] source = "escrow/src/lib.rs" diff --git a/docs/guide/smart-vaults.md b/docs/guide/smart-vaults.md index 1bab752..775e41b 100644 --- a/docs/guide/smart-vaults.md +++ b/docs/guide/smart-vaults.md @@ -257,10 +257,11 @@ bedrock init my-vault --primitives vault --template vault-whitelist Vault projects use this `bedrock.toml` structure: ```toml +primitives = ["vault"] + [project] name = "my-vault" version = "0.1.0" -primitives = ["vault"] [vaults.main] source = "vault/src/lib.rs" diff --git a/internal/cli/init.go b/internal/cli/init.go index 4558577..4ad0b53 100644 --- a/internal/cli/init.go +++ b/internal/cli/init.go @@ -210,13 +210,11 @@ func scaffoldPrimitive(projectName, p string) error { func generateConfig(projectName string, selected []string) error { var buf strings.Builder - buf.WriteString(fmt.Sprintf(`[project] -name = "%s" -version = "0.1.0" -authors = ["Your Name"] - -primitives = [`, projectName)) - + // `primitives` is a root-level key in bedrock.toml: it must precede the + // first table header. Emitting it under [project] would make TOML parse it + // as `project.primitives`, which Config never reads (cfg.Primitives loads + // empty and is only repopulated by the section-inference fallback in Load). + buf.WriteString("primitives = [") for i, p := range selected { if i > 0 { buf.WriteString(", ") @@ -225,6 +223,13 @@ primitives = [`, projectName)) } buf.WriteString("]\n\n") + buf.WriteString(fmt.Sprintf(`[project] +name = "%s" +version = "0.1.0" +authors = ["Your Name"] + +`, projectName)) + // Build section (only for contract) for _, p := range selected { if p == primitives.Contract { diff --git a/llms.txt b/llms.txt index 16f7331..18c52dd 100644 --- a/llms.txt +++ b/llms.txt @@ -138,10 +138,11 @@ my-vault/ ### Contract Project ```toml +primitives = ["contract"] + [project] name = "my-contract" version = "0.1.0" -primitives = ["contract"] [build] source = "contract/src/lib.rs" @@ -170,10 +171,11 @@ faucet_url = "https://alphanet.faucet.nerdnest.xyz/accounts" ### Escrow Project ```toml +primitives = ["escrow"] + [project] name = "my-escrow" version = "0.1.0" -primitives = ["escrow"] [escrows.main] source = "escrow/src/lib.rs" @@ -185,10 +187,11 @@ docker_image = "lejamon/rippled_smart_contract_vault_x86" ### Vault Project ```toml +primitives = ["vault"] + [project] name = "my-vault" version = "0.1.0" -primitives = ["vault"] [vaults.main] source = "vault/src/lib.rs" diff --git a/pkg/config/loader_test.go b/pkg/config/loader_test.go index 792403f..dd8c86e 100644 --- a/pkg/config/loader_test.go +++ b/pkg/config/loader_test.go @@ -17,13 +17,15 @@ func writeTOML(t *testing.T, content string) string { } func TestLoadParsesProjectAndNetworks(t *testing.T) { + // `primitives` is a root-level key, so it must come before the first table + // header — placing it under [project] would make Load silently drop it. path := writeTOML(t, ` +primitives = ["contract"] + [project] name = "demo" version = "0.1.0" -primitives = ["contract"] - [networks.local] url = "ws://localhost:6006" network_id = 100 @@ -47,14 +49,18 @@ network_id = 100 if net.NetworkID != 100 { t.Errorf("local.NetworkID = %d, want 100", net.NetworkID) } + if !cfg.HasPrimitive("contract") { + t.Errorf("expected root-level primitive 'contract' to load, got %v", cfg.Primitives) + } } // Load fills in local-node defaults when the section is absent. func TestLoadAppliesLocalNodeDefaults(t *testing.T) { path := writeTOML(t, ` +primitives = ["contract"] + [project] name = "demo" -primitives = ["contract"] `) cfg, err := Load(path)