Skip to content

Commit a364974

Browse files
stackdumpclaude
andcommitted
Add bitwrap -validate: full .btw → Solidity → forge → anvil pipeline
New CLI flag validates a .btw DSL file through the complete pipeline: parse → build schema → generate Solidity + tests → forge build → forge test → deploy to anvil Usage: ./bitwrap -validate path/to/file.btw make validate BTW=path/to/file.btw Also fixes DSL parser to handle nested map types like map[address]map[address]uint256 (recursive parseTypeString). Includes examples/erc20.btw as a working reference that passes full validation including deployment. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 35be94b commit a364974

4 files changed

Lines changed: 224 additions & 4 deletions

File tree

Makefile

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
.PHONY: build run test test-e2e test-playwright clean wasm
1+
.PHONY: build run test test-e2e test-playwright validate clean wasm
22

33
PORT ?= 8088
44

@@ -18,6 +18,9 @@ test:
1818
test-e2e:
1919
go test -tags e2e -timeout 600s -v ./internal/server/ -run TestFoundryE2E
2020

21+
validate: build
22+
./bitwrap -validate $(BTW)
23+
2124
test-playwright:
2225
cd e2e && npm install --silent && npx playwright install chromium 2>/dev/null; cd e2e && npx playwright test
2326

cmd/bitwrap/main.go

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,19 @@ import (
55
"flag"
66
"fmt"
77
"log"
8+
"net"
89
"net/http"
910
"os"
11+
"os/exec"
12+
"path/filepath"
13+
"strings"
14+
"time"
1015

1116
"github.com/stackdump/bitwrap-io/dsl"
1217
"github.com/stackdump/bitwrap-io/internal/server"
1318
"github.com/stackdump/bitwrap-io/public"
1419
"github.com/stackdump/bitwrap-io/internal/store"
20+
"github.com/stackdump/bitwrap-io/solidity"
1521
)
1622

1723
func main() {
@@ -21,6 +27,7 @@ func main() {
2127
noSolgen := flag.Bool("no-solgen", false, "Disable Solidity generation endpoints")
2228
keyDir := flag.String("key-dir", "", "Directory for persistent circuit keys (enables fast restarts)")
2329
compile := flag.String("compile", "", "Compile a .btw file and output JSON schema to stdout")
30+
validate := flag.String("validate", "", "Validate a .btw file: compile → generate Solidity → forge build → forge test → deploy")
2431
flag.Parse()
2532

2633
if *compile != "" {
@@ -44,6 +51,10 @@ func main() {
4451
return
4552
}
4653

54+
if *validate != "" {
55+
os.Exit(runValidate(*validate))
56+
}
57+
4758
storage := store.NewFSStore(*dataDir)
4859

4960
publicFS, err := public.FS()
@@ -70,3 +81,149 @@ func main() {
7081
log.Fatalf("Server failed: %v", err)
7182
}
7283
}
84+
85+
// runValidate compiles a .btw file through the full pipeline:
86+
// parse → build schema → generate Solidity + tests → forge build → forge test → deploy to anvil
87+
func runValidate(path string) int {
88+
src, err := os.ReadFile(path)
89+
if err != nil {
90+
fmt.Fprintf(os.Stderr, "FAIL: read %s: %v\n", path, err)
91+
return 1
92+
}
93+
94+
// Step 1: Parse DSL
95+
fmt.Printf("● parse %s\n", path)
96+
ast, err := dsl.Parse(string(src))
97+
if err != nil {
98+
fmt.Fprintf(os.Stderr, "FAIL: parse: %v\n", err)
99+
return 1
100+
}
101+
102+
// Step 2: Build metamodel schema
103+
fmt.Printf("● build schema: %s\n", ast.Name)
104+
schema, err := dsl.Build(ast)
105+
if err != nil {
106+
fmt.Fprintf(os.Stderr, "FAIL: build: %v\n", err)
107+
return 1
108+
}
109+
fmt.Printf(" states: %d, actions: %d, arcs: %d\n",
110+
len(schema.States), len(schema.Actions), len(schema.InputArcs(""))+len(schema.OutputArcs("")))
111+
112+
// Step 3: Generate Solidity
113+
contractName := solidity.ContractName(schema.Name)
114+
contractCode := solidity.Generate(schema)
115+
testCode := solidity.GenerateTests(schema)
116+
genesisCode := solidity.GenerateGenesis(schema.Name, solidity.GenesisConfig{}, solidity.DefaultAddresses())
117+
fmt.Printf("● generate Solidity: %s.sol (%d bytes)\n", contractName, len(contractCode))
118+
fmt.Printf(" tests: %sTest.t.sol (%d bytes)\n", contractName, len(testCode))
119+
fmt.Printf(" genesis: %sGenesis.s.sol (%d bytes)\n", contractName, len(genesisCode))
120+
121+
// Step 4: Check for forge
122+
if _, err := exec.LookPath("forge"); err != nil {
123+
fmt.Printf("● forge not installed — skipping compilation and deployment\n")
124+
fmt.Printf("PASS (parse + generate only)\n")
125+
return 0
126+
}
127+
128+
// Step 5: Set up temp Foundry project
129+
dir, err := os.MkdirTemp("", "bitwrap-validate-*")
130+
if err != nil {
131+
fmt.Fprintf(os.Stderr, "FAIL: tmpdir: %v\n", err)
132+
return 1
133+
}
134+
defer os.RemoveAll(dir)
135+
136+
for _, sub := range []string{"src", "test", "script"} {
137+
os.MkdirAll(filepath.Join(dir, sub), 0o755)
138+
}
139+
140+
foundryToml := "[profile.default]\nsrc = \"src\"\nout = \"out\"\nlibs = [\"lib\"]\nsolc_version = \"0.8.20\"\n"
141+
os.WriteFile(filepath.Join(dir, "foundry.toml"), []byte(foundryToml), 0o644)
142+
os.WriteFile(filepath.Join(dir, "src", contractName+".sol"), []byte(contractCode), 0o644)
143+
os.WriteFile(filepath.Join(dir, "test", contractName+"Test.t.sol"), []byte(testCode), 0o644)
144+
os.WriteFile(filepath.Join(dir, "script", contractName+"Genesis.s.sol"), []byte(genesisCode), 0o644)
145+
146+
// Step 6: Install forge-std
147+
if !runStep(dir, "install forge-std", "git", "init") {
148+
return 1
149+
}
150+
if !runStep(dir, "install forge-std", "forge", "install", "foundry-rs/forge-std") {
151+
return 1
152+
}
153+
154+
// Step 7: Compile
155+
if !runStep(dir, "forge build", "forge", "build") {
156+
return 1
157+
}
158+
159+
// Step 8: Test
160+
if !runStep(dir, "forge test", "forge", "test", "-vv") {
161+
return 1
162+
}
163+
164+
// Step 9: Deploy to anvil
165+
if _, err := exec.LookPath("anvil"); err != nil {
166+
fmt.Printf("● anvil not installed — skipping deployment\n")
167+
fmt.Printf("PASS\n")
168+
return 0
169+
}
170+
171+
listener, err := net.Listen("tcp", "127.0.0.1:0")
172+
if err != nil {
173+
fmt.Fprintf(os.Stderr, "FAIL: find free port: %v\n", err)
174+
return 1
175+
}
176+
port := listener.Addr().(*net.TCPAddr).Port
177+
listener.Close()
178+
179+
rpcURL := fmt.Sprintf("http://127.0.0.1:%d", port)
180+
anvil := exec.Command("anvil", "--port", fmt.Sprintf("%d", port), "--silent")
181+
if err := anvil.Start(); err != nil {
182+
fmt.Fprintf(os.Stderr, "FAIL: start anvil: %v\n", err)
183+
return 1
184+
}
185+
defer func() { anvil.Process.Kill(); anvil.Wait() }()
186+
187+
// Wait for anvil
188+
for i := 0; i < 50; i++ {
189+
cmd := exec.Command("cast", "chain-id", "--rpc-url", rpcURL)
190+
if out, err := cmd.CombinedOutput(); err == nil && strings.TrimSpace(string(out)) == "31337" {
191+
break
192+
}
193+
time.Sleep(100 * time.Millisecond)
194+
}
195+
196+
privKey := "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"
197+
createArgs := []string{"create", fmt.Sprintf("src/%s.sol:%s", contractName, contractName),
198+
"--rpc-url", rpcURL, "--private-key", privKey, "--broadcast"}
199+
200+
cmd := exec.Command("forge", createArgs...)
201+
cmd.Dir = dir
202+
out, err := cmd.CombinedOutput()
203+
if err != nil || !strings.Contains(string(out), "Deployed to:") {
204+
fmt.Fprintf(os.Stderr, "● deploy FAIL\n%s\n", out)
205+
return 1
206+
}
207+
208+
// Extract address
209+
for _, line := range strings.Split(string(out), "\n") {
210+
if strings.Contains(line, "Deployed to:") {
211+
fmt.Printf("● deploy: %s\n", strings.TrimSpace(line))
212+
}
213+
}
214+
215+
fmt.Printf("PASS\n")
216+
return 0
217+
}
218+
219+
func runStep(dir, label, name string, args ...string) bool {
220+
fmt.Printf("● %s\n", label)
221+
cmd := exec.Command(name, args...)
222+
cmd.Dir = dir
223+
out, err := cmd.CombinedOutput()
224+
if err != nil {
225+
fmt.Fprintf(os.Stderr, "FAIL: %s\n%s\n", label, out)
226+
return false
227+
}
228+
return true
229+
}

dsl/parser.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,8 @@ func (p *Parser) parseRegister() (Register, error) {
207207
}, nil
208208
}
209209

210-
// parseTypeString reads a type like "uint256" or "map[address]uint256".
210+
// parseTypeString reads a type like "uint256", "map[address]uint256",
211+
// or nested maps like "map[address]map[address]uint256".
211212
func (p *Parser) parseTypeString() (string, error) {
212213
tok := p.advance()
213214
if tok.Type != TokenIdent {
@@ -225,11 +226,12 @@ func (p *Parser) parseTypeString() (string, error) {
225226
if _, err := p.expect(TokenRBracket); err != nil {
226227
return "", err
227228
}
228-
valTok, err := p.expect(TokenIdent)
229+
// Value type can itself be a map (recursive) or a simple ident
230+
valType, err := p.parseTypeString()
229231
if err != nil {
230232
return "", err
231233
}
232-
result += "[" + keyTok.Value + "]" + valTok.Value
234+
result += "[" + keyTok.Value + "]" + valType
233235
return result, nil
234236
}
235237

examples/erc20.btw

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
schema ERC20 {
2+
version "1.0.0"
3+
domain "custody"
4+
asset "erc20"
5+
6+
register BALANCES map[address]uint256 observable
7+
register TOTAL_SUPPLY uint256
8+
9+
event TransferEvent {
10+
from: address indexed
11+
to: address indexed
12+
amount: uint256
13+
}
14+
15+
event MintEvent {
16+
to: address indexed
17+
amount: uint256
18+
}
19+
20+
event BurnEvent {
21+
from: address indexed
22+
amount: uint256
23+
}
24+
25+
fn(transfer) {
26+
var from address
27+
var to address
28+
var amount amount
29+
30+
require(BALANCES[from] >= amount && to != address(0))
31+
@event TransferEvent
32+
33+
BALANCES[from] -|amount|> transfer
34+
transfer -|amount|> BALANCES[to]
35+
}
36+
37+
fn(mint) {
38+
var to address
39+
var amount amount
40+
41+
require(to != address(0))
42+
@event MintEvent
43+
44+
mint -|amount|> BALANCES[to]
45+
mint -|amount|> TOTAL_SUPPLY
46+
}
47+
48+
fn(burn) {
49+
var from address
50+
var amount amount
51+
52+
require(BALANCES[from] >= amount)
53+
@event BurnEvent
54+
55+
BALANCES[from] -|amount|> burn
56+
TOTAL_SUPPLY -|amount|> burn
57+
}
58+
}

0 commit comments

Comments
 (0)