Skip to content

Commit 8158982

Browse files
stackdumpclaude
andcommitted
Go/JS/Solidity execution parity for Petri net runtime
Rewrites state.js to match Go metamodel.Runtime semantics: - Arc weights from bindings (was hardcoded to 1) - Keyed arc data transformations (single and nested maps) - Read-arc detection (skip input decrements for read-then-write) - Literal arc weight parsing (Value="1" → integer 1) - executeWithBindings() for full colored Petri net execution Adds parity_test.mjs with three test cases run in both Go and JS: - ERC20: mint(1000) → transfer(300) → burn(100) — verifies balances and totalSupply - Counter: increment(5) → decrement(2) — verifies scalar arc weights - Allowances: mint → approve → transferFrom — verifies nested map access The Go test suite now shells out to Node.js to verify JS produces identical state, and CI runs the same test in the js-parity job. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 344bd8c commit 8158982

4 files changed

Lines changed: 385 additions & 18 deletions

File tree

.github/workflows/ci.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,10 @@ jobs:
113113
});
114114
"
115115
116+
- name: Petri net execution parity
117+
run: |
118+
node --experimental-vm-modules public/parity_test.mjs
119+
116120
- name: JS syntax check
117121
run: |
118122
for f in public/mimc.js public/merkle.js public/witness-builder.js public/safemath.js public/model.js public/state.js public/bridge.js public/bitwrap.js; do

internal/server/runtime_parity_test.go

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
package server
22

33
import (
4+
"os"
5+
"os/exec"
6+
"path/filepath"
47
"testing"
58

69
"github.com/stackdump/bitwrap-io/dsl"
@@ -154,6 +157,118 @@ schema Counter {
154157
t.Errorf("after decrement(2): COUNT=%d, want 3", rt.Tokens("COUNT"))
155158
}
156159
})
160+
161+
t.Run("allowances", func(t *testing.T) {
162+
src := `
163+
schema ERC20Approve {
164+
version "1.0.0"
165+
register BALANCES map[address]uint256
166+
register ALLOWANCES map[address]map[address]uint256
167+
168+
fn(mint) {
169+
var to address
170+
var amount amount
171+
mint -|amount|> BALANCES[to]
172+
}
173+
174+
fn(approve) {
175+
var owner address
176+
var spender address
177+
var amount amount
178+
approve -|amount|> ALLOWANCES[owner][spender]
179+
}
180+
181+
fn(transferFrom) {
182+
var from address
183+
var spender address
184+
var to address
185+
var amount amount
186+
BALANCES[from] -|amount|> transferFrom
187+
ALLOWANCES[from][spender] -|amount|> transferFrom
188+
transferFrom -|amount|> BALANCES[to]
189+
}
190+
}
191+
`
192+
ast, err := dsl.Parse(src)
193+
if err != nil {
194+
t.Fatal(err)
195+
}
196+
schema, err := dsl.Build(ast)
197+
if err != nil {
198+
t.Fatal(err)
199+
}
200+
201+
rt := metamodel.NewRuntime(schema)
202+
rt.CheckConstraints = false
203+
204+
// Mint 1000 to Alice
205+
err = rt.ExecuteWithBindings("mint", metamodel.Bindings{"to": "Alice", "amount": int64(1000)})
206+
if err != nil {
207+
t.Fatalf("mint: %v", err)
208+
}
209+
210+
// Alice approves Bob for 500
211+
err = rt.ExecuteWithBindings("approve", metamodel.Bindings{"owner": "Alice", "spender": "Bob", "amount": int64(500)})
212+
if err != nil {
213+
t.Fatalf("approve: %v", err)
214+
}
215+
allowances := rt.DataMap("ALLOWANCES")
216+
nested, ok := allowances["Alice"].(map[string]any)
217+
if !ok {
218+
t.Fatal("ALLOWANCES[Alice] is not a map")
219+
}
220+
if mapVal(nested, "Bob") != 500 {
221+
t.Errorf("after approve: ALLOWANCES[Alice][Bob]=%v, want 500", nested["Bob"])
222+
}
223+
224+
// Bob transfers 200 from Alice to Charlie
225+
err = rt.ExecuteWithBindings("transferFrom", metamodel.Bindings{
226+
"from": "Alice", "spender": "Bob", "to": "Charlie", "amount": int64(200),
227+
})
228+
if err != nil {
229+
t.Fatalf("transferFrom: %v", err)
230+
}
231+
232+
balances := rt.DataMap("BALANCES")
233+
if mapVal(balances, "Alice") != 800 {
234+
t.Errorf("after transferFrom: BALANCES[Alice]=%v, want 800", balances["Alice"])
235+
}
236+
if mapVal(balances, "Charlie") != 200 {
237+
t.Errorf("after transferFrom: BALANCES[Charlie]=%v, want 200", balances["Charlie"])
238+
}
239+
allowances = rt.DataMap("ALLOWANCES")
240+
nested = allowances["Alice"].(map[string]any)
241+
if mapVal(nested, "Bob") != 300 {
242+
t.Errorf("after transferFrom: ALLOWANCES[Alice][Bob]=%v, want 300", nested["Bob"])
243+
}
244+
})
245+
246+
// Verify JS parity by running the JS test
247+
t.Run("js_parity", func(t *testing.T) {
248+
cmd := exec.Command("node", "--experimental-vm-modules", "public/parity_test.mjs")
249+
cmd.Dir = findProjectRoot(t)
250+
out, err := cmd.CombinedOutput()
251+
if err != nil {
252+
t.Fatalf("JS parity test failed:\n%s\n%v", out, err)
253+
}
254+
t.Logf("%s", out)
255+
})
256+
}
257+
258+
func findProjectRoot(t *testing.T) string {
259+
t.Helper()
260+
// Walk up from the test binary's working directory to find go.mod
261+
dir, _ := os.Getwd()
262+
for {
263+
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
264+
return dir
265+
}
266+
parent := filepath.Dir(dir)
267+
if parent == dir {
268+
t.Fatal("could not find project root")
269+
}
270+
dir = parent
271+
}
157272
}
158273

159274
func mapVal(m map[string]any, key string) int64 {

public/parity_test.mjs

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
// Petri net execution parity test — must match Go TestRuntimeSolidityParity.
2+
// Run: node --experimental-vm-modules public/parity_test.mjs
3+
4+
import { Model } from './model.js';
5+
import { State } from './state.js';
6+
7+
let failures = 0;
8+
9+
function assert(condition, msg) {
10+
if (!condition) {
11+
console.error('FAIL:', msg);
12+
failures++;
13+
}
14+
}
15+
16+
// ============ ERC20: mint → transfer → burn ============
17+
function testERC20() {
18+
const m = new Model('ERC20');
19+
m.addPlace({ id: 'BALANCES', schema: 'map[address]uint256', exported: true });
20+
m.addPlace({ id: 'TOTAL_SUPPLY', schema: 'uint256', initial: 0 });
21+
m.addTransition({ id: 'mint' });
22+
m.addTransition({ id: 'transfer' });
23+
m.addTransition({ id: 'burn' });
24+
25+
// mint arcs: mint -|amount|> BALANCES[to], mint -|amount|> TOTAL_SUPPLY
26+
m.addArc({ source: 'mint', target: 'BALANCES', keys: ['to'], value: 'amount' });
27+
m.addArc({ source: 'mint', target: 'TOTAL_SUPPLY', value: 'amount' });
28+
29+
// transfer arcs: BALANCES[from] -|amount|> transfer, transfer -|amount|> BALANCES[to]
30+
m.addArc({ source: 'BALANCES', target: 'transfer', keys: ['from'], value: 'amount' });
31+
m.addArc({ source: 'transfer', target: 'BALANCES', keys: ['to'], value: 'amount' });
32+
33+
// burn arcs: BALANCES[from] -|amount|> burn, TOTAL_SUPPLY -|amount|> burn
34+
m.addArc({ source: 'BALANCES', target: 'burn', keys: ['from'], value: 'amount' });
35+
m.addArc({ source: 'TOTAL_SUPPLY', target: 'burn', value: 'amount' });
36+
37+
const s = new State(m);
38+
39+
// Mint 1000 to Alice
40+
s.executeWithBindings('mint', { to: 'Alice', amount: 1000 });
41+
assert(s.getDataMap('BALANCES')['Alice'] === 1000, `ERC20 mint: BALANCES[Alice]=${s.getDataMap('BALANCES')['Alice']}, want 1000`);
42+
assert(s.getTokens('TOTAL_SUPPLY') === 1000, `ERC20 mint: TOTAL_SUPPLY=${s.getTokens('TOTAL_SUPPLY')}, want 1000`);
43+
44+
// Transfer 300 from Alice to Bob
45+
s.executeWithBindings('transfer', { from: 'Alice', to: 'Bob', amount: 300 });
46+
assert(s.getDataMap('BALANCES')['Alice'] === 700, `ERC20 transfer: BALANCES[Alice]=${s.getDataMap('BALANCES')['Alice']}, want 700`);
47+
assert(s.getDataMap('BALANCES')['Bob'] === 300, `ERC20 transfer: BALANCES[Bob]=${s.getDataMap('BALANCES')['Bob']}, want 300`);
48+
assert(s.getTokens('TOTAL_SUPPLY') === 1000, `ERC20 transfer: TOTAL_SUPPLY=${s.getTokens('TOTAL_SUPPLY')}, want 1000`);
49+
50+
// Burn 100 from Alice
51+
s.executeWithBindings('burn', { from: 'Alice', amount: 100 });
52+
assert(s.getDataMap('BALANCES')['Alice'] === 600, `ERC20 burn: BALANCES[Alice]=${s.getDataMap('BALANCES')['Alice']}, want 600`);
53+
assert(s.getTokens('TOTAL_SUPPLY') === 900, `ERC20 burn: TOTAL_SUPPLY=${s.getTokens('TOTAL_SUPPLY')}, want 900`);
54+
55+
console.log('ERC20 parity: OK');
56+
}
57+
58+
// ============ Counter: increment → decrement ============
59+
function testCounter() {
60+
const m = new Model('Counter');
61+
m.addPlace({ id: 'COUNT', schema: 'uint256', initial: 0 });
62+
m.addTransition({ id: 'increment' });
63+
m.addTransition({ id: 'decrement' });
64+
65+
m.addArc({ source: 'increment', target: 'COUNT', value: 'amount' });
66+
m.addArc({ source: 'COUNT', target: 'decrement', value: 'amount' });
67+
68+
const s = new State(m);
69+
70+
// Increment by 5
71+
s.executeWithBindings('increment', { amount: 5 });
72+
assert(s.getTokens('COUNT') === 5, `Counter increment: COUNT=${s.getTokens('COUNT')}, want 5`);
73+
74+
// Decrement by 2
75+
s.executeWithBindings('decrement', { amount: 2 });
76+
assert(s.getTokens('COUNT') === 3, `Counter decrement: COUNT=${s.getTokens('COUNT')}, want 3`);
77+
78+
console.log('Counter parity: OK');
79+
}
80+
81+
// ============ Nested maps: approve + transferFrom ============
82+
function testAllowances() {
83+
const m = new Model('ERC20Approve');
84+
m.addPlace({ id: 'BALANCES', schema: 'map[address]uint256' });
85+
m.addPlace({ id: 'ALLOWANCES', schema: 'map[address]map[address]uint256' });
86+
m.addTransition({ id: 'mint' });
87+
m.addTransition({ id: 'approve' });
88+
m.addTransition({ id: 'transferFrom' });
89+
90+
m.addArc({ source: 'mint', target: 'BALANCES', keys: ['to'], value: 'amount' });
91+
m.addArc({ source: 'approve', target: 'ALLOWANCES', keys: ['owner', 'spender'], value: 'amount' });
92+
m.addArc({ source: 'BALANCES', target: 'transferFrom', keys: ['from'], value: 'amount' });
93+
m.addArc({ source: 'ALLOWANCES', target: 'transferFrom', keys: ['from', 'spender'], value: 'amount' });
94+
m.addArc({ source: 'transferFrom', target: 'BALANCES', keys: ['to'], value: 'amount' });
95+
96+
const s = new State(m);
97+
98+
// Mint 1000 to Alice
99+
s.executeWithBindings('mint', { to: 'Alice', amount: 1000 });
100+
assert(s.getDataMap('BALANCES')['Alice'] === 1000, 'mint balance');
101+
102+
// Alice approves Bob for 500
103+
s.executeWithBindings('approve', { owner: 'Alice', spender: 'Bob', amount: 500 });
104+
const allowances = s.getDataMap('ALLOWANCES');
105+
assert(allowances['Alice'] && allowances['Alice']['Bob'] === 500, `approve: ALLOWANCES[Alice][Bob]=${allowances['Alice']?.['Bob']}, want 500`);
106+
107+
// Bob transfers 200 from Alice to Charlie
108+
s.executeWithBindings('transferFrom', { from: 'Alice', spender: 'Bob', to: 'Charlie', amount: 200 });
109+
assert(s.getDataMap('BALANCES')['Alice'] === 800, `transferFrom: BALANCES[Alice]=${s.getDataMap('BALANCES')['Alice']}, want 800`);
110+
assert(s.getDataMap('BALANCES')['Charlie'] === 200, `transferFrom: BALANCES[Charlie]=${s.getDataMap('BALANCES')['Charlie']}, want 200`);
111+
assert(s.getDataMap('ALLOWANCES')['Alice']['Bob'] === 300, `transferFrom: ALLOWANCES[Alice][Bob]=${s.getDataMap('ALLOWANCES')['Alice']['Bob']}, want 300`);
112+
113+
console.log('Allowances parity: OK');
114+
}
115+
116+
testERC20();
117+
testCounter();
118+
testAllowances();
119+
120+
if (failures > 0) {
121+
console.error(`\n${failures} parity failures`);
122+
process.exit(1);
123+
} else {
124+
console.log('\nPetri net execution parity: all tests passed');
125+
}

0 commit comments

Comments
 (0)