Repository navigation
Expand file tree
/
Copy pathjustfile
More file actions
1709 lines (1653 loc) · 104 KB
/
Copy pathjustfile
File metadata and controls
1709 lines (1653 loc) · 104 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# Self-service proving and claiming of finalized deposits (OPERATOR.md, "Taking over a stalled deposit").
#
# Each recipe belongs to one group, which says what it may touch:
# read public reads (Esplora, Sepolia RPC); writes only under BRIDGE_WORK
# build local compilation; the network only for crates, pinned deps and the SP1 Docker image
# prove Sepolia RPC reads and local SP1 execution or proving
# key creates or reads the fee key file
# sign builds and signs Liquid transactions into BRIDGE_WORK; sends nothing
# assemble builds an unsigned Liquid update or claim against an open Humid fee coin; sends nothing
# publish writes the instance's light-client store files in the working tree; no git, no upload
# deploy prepares a new instance under DEPLOY_WORK and writes its record; only deploy-escrow sends, to
# Sepolia, after a simulation and a confirmation prompt
# broadcast sends one signed file to Esplora, after its checks and a confirmation prompt
#
# Settings come from the environment or `just NAME=value <recipe>`. RPC URLs are read only from the
# environment, by the tools, and no recipe prints them. `just config` shows what is in effect.
set dotenv-load := false
set export
# The instance directory; its instance.json pins the escrow, deployment block and guest. The default is the
# instance the published site serves (bridge-core/config.js INSTANCE). Every chain walk prints which one it targets.
BRIDGE_INSTANCE := env("BRIDGE_INSTANCE", "instances/sepolia-liquid-testnet")
# Optional cross-check: if set, it must equal the instance's ESCROW. The escrow always comes from the instance.
BRIDGE_ESCROW := env("BRIDGE_ESCROW", "")
# Where recovered state, proofs and transactions are written. Keep it under the ignored target/.
BRIDGE_WORK := env("BRIDGE_WORK", "target/takeover")
# Light-client preimages for `recover`. Set it empty to recover without them (enough to claim, not to update).
BRIDGE_HINT := env("BRIDGE_HINT", BRIDGE_INSTANCE / "lc_preimages.json")
BRIDGE_ELF := env("BRIDGE_ELF", BRIDGE_WORK / "elf/sg16-bridge-program")
# The live head's store as `just reconstruct` rebuilds it when the update reaching that head published none.
# `recover` tries it after BRIDGE_HINT, and uses it only if it hashes to the live lc.
REBUILT_STORE := BRIDGE_WORK / "reconstructed.store.json"
LIQUID_ESPLORA := env("LIQUID_ESPLORA", "https://blockstream.info/liquidtestnet/api")
# Path of the Liquid testnet fee key file. No default: signing recipes refuse to run without it.
LIQUID_FEE_KEY := env("LIQUID_FEE_KEY", "")
# An update is broadcast only after its store file and lc_preimages.json are read back from a public HTTPS copy of
# this instance directory. For the escrow the published site serves, that copy is the published site's record,
# bridge-core/config.js PUBLISHED.site + instances/<name>, the one the page and the next prover look in; there this
# is an optional cross-check and must equal it. For any other instance, set it to the URL where you publish.
STORE_READBACK_URL := env("STORE_READBACK_URL", "")
# Fee slots and split fee, in sats: the costs recorded so far (OPERATOR.md), not minimums.
FEE_UPDATE_SATS := env("FEE_UPDATE_SATS", "2000")
FEE_CLAIM_SATS := env("FEE_CLAIM_SATS", "2000")
SPLIT_FEE_SATS := env("SPLIT_FEE_SATS", "300")
# Initial maximum; bridge-operator reduces it automatically when an RPC enforces a smaller eth_getLogs range.
DEPOSIT_LOG_STEP := env("DEPOSIT_LOG_STEP", "2000")
# Delay between Sepolia RPC requests; bridge-operator increases it if the provider rate-limits.
DEPOSIT_LOG_PACE := env("DEPOSIT_LOG_PACE", "250")
WAIT_SECONDS := env("WAIT_SECONDS", "30")
WAIT_TRIES := env("WAIT_TRIES", "60")
# Deployment of a new instance (the deploy group, OPERATOR.md "Deploying the instance"). Fixed: foundry.toml lets
# the deployment script read and write this directory and no other.
DEPLOY_WORK := "target/deploy"
# The deployer's Sepolia address, and its encrypted key, one of two ways: DEPLOY_ACCOUNT, the name of a keystore in
# Foundry's own directory as `cast wallet list` shows it (cast asks for its password); or DEPLOY_KEYSTORE, a keystore
# file, with DEPLOY_PASSWORD_FILE, a file holding its password, both outside Git (under the ignored target/ or
# outside the repository). Only cast and forge read the password, from the file; no recipe prints it.
DEPLOYER := env("DEPLOYER", "")
DEPLOY_ACCOUNT := env("DEPLOY_ACCOUNT", "")
DEPLOY_KEYSTORE := env("DEPLOY_KEYSTORE", "")
DEPLOY_PASSWORD_FILE := env("DEPLOY_PASSWORD_FILE", "")
# The guest build the new instance pins: `just prover` from this checkout (SP1 6.8.1, Docker image v6.8.1).
# deploy-record refuses an ELF that does not give exactly these.
DEPLOY_GUEST_ELF_SHA256 := "77d2a9d8850bad5cc7c95ed85d3b3aa00fd8e0e19c4699078283decc829a39a7"
DEPLOY_PROGRAM_VKEY := "004f372912b3a05c305ed7abc9d41c4945ca7c81d24145e857e887294847cd74"
# Sepolia's Amsterdam fork charges state gas for deployed bytecode. Foundry's Prague simulator omits it, so use a
# larger transaction limit and compare it with eth_estimateGas on Sepolia before signing.
DEPLOY_GAS_MULTIPLIER := "800"
ASSEMBLE_PY := "tools/sg16/bridge-guest/testnet/assemble.py"
TX_BIN := "tools/sg16/bridge-guest/target/testnet-tx/release/sg16-testnet"
WALLET_BIN := "tools/sg16/liquid-testnet-wallet/target/release/sg16-liquid-testnet-wallet"
BRIDGE_HOST_BIN := "tools/sg16/bridge-guest/target/release/sg16-bridge-host"
OPERATOR_BIN := "tools/bridge-operator/target/release/bridge-operator"
# SP1 v6.8.1. Install this source revision, rather than whichever release sp1up currently selects.
SP1_CLI_REV := "c84ada1ed5911f28c4d3c9d0ed2f9e6cd7edb824"
# Shell helpers shared by the recipes below. Values reach them only as environment variables, never by
# interpolation into the script text.
_prelude := '''
set -euo pipefail
die() { printf 'error: %s\n' "$*" >&2; exit 1; }
need() { [ -e "$1" ] || die "$1 is missing${2:+; $2}"; }
need_env() { [ -n "${!1:-}" ] || die "set $1 to $2; it is read from the environment and never printed"; }
need_key() {
[ -n "$LIQUID_FEE_KEY" ] || die "set LIQUID_FEE_KEY to your Liquid testnet fee key file (just fee-key-new creates one)"
need "$LIQUID_FEE_KEY"
}
hex32() { [[ "$1" =~ ^[0-9a-f]{64}$ ]] || die "$2 must be 64 lowercase hex characters, got '$1'"; }
jget() { # jget <file|-> <key>...: one JSON value; a list prints one item per line, null prints nothing
python3 -c '
import json, sys
path, keys = sys.argv[1], sys.argv[2:]
v = json.load(sys.stdin if path == "-" else open(path))
try:
for k in keys:
v = v[int(k)] if isinstance(v, list) else v[k]
except (KeyError, IndexError, ValueError):
sys.exit(f"{path}: no {keys}")
if isinstance(v, list):
print("\n".join(map(str, v)))
elif isinstance(v, bool):
print(str(v).lower())
elif v is not None:
print(v)
' "$@"
}
escrow() {
python3 -c '
import json, sys
pinned = json.load(open(sys.argv[1]))["ESCROW"].lower().removeprefix("0x")
given = sys.argv[2].lower().removeprefix("0x")
if given and given != pinned:
sys.exit(f"BRIDGE_ESCROW 0x{given} is not the escrow {sys.argv[1]} pins, 0x{pinned}")
print("0x" + pinned)
' "$BRIDGE_INSTANCE/instance.json" "$BRIDGE_ESCROW"
}
site() { # whether the published site serves BRIDGE_INSTANCE: "served", or "not served: <every difference>"
# Served means all of bridge-core/config.js INSTANCE: BRIDGE_INSTANCE is its record directory instances/<name>, and that
# directory's escrow, code hash, bootstrap txid and manifest state address are the ones the page pins. An
# escrow alone does not decide it: a copied or edited record with the same escrow is not what Pages publishes.
local e b=""; e=$(escrow)
[ ! -f "$BRIDGE_INSTANCE/bootstrap.hex" ] || b=$(txid_of "$BRIDGE_INSTANCE/bootstrap.hex") || b=""
node --input-type=module - "$BRIDGE_INSTANCE" "$e" "$b" <<'JS' || die "could not compare $BRIDGE_INSTANCE with bridge-core/config.js INSTANCE"
import { existsSync, readFileSync, realpathSync } from 'node:fs';
const [dir, escrow, bootstrap] = process.argv.slice(2);
const { INSTANCE } = await import('./bridge-core/config.js');
if (INSTANCE === null) { console.log('not served: the published site serves no instance'); process.exit(0); }
const read = (f) => { try { return JSON.parse(readFileSync(`${dir}/${f}`, 'utf8')); } catch { return {}; } };
const record = `instances/${INSTANCE.name}`;
const short = (h) => (h ? `${h.slice(0, 10)}…` : 'none');
const differ = [];
if (escrow !== INSTANCE.escrow.toLowerCase()) differ.push(`escrow ${escrow} is not the site's ${INSTANCE.escrow.toLowerCase()}`);
if (!existsSync(record) || !existsSync(dir) || realpathSync(dir) !== realpathSync(record)) differ.push(`directory is not ${record}`);
const code = String(read('instance.json').ESCROW_CODE_HASH ?? '').toLowerCase().replace(/^0x/, '');
if (code !== INSTANCE.codeHash) differ.push(`escrow code hash ${short(code)} is not the site's ${short(INSTANCE.codeHash)}`);
if (bootstrap !== INSTANCE.bootstrap) differ.push(`bootstrap ${short(bootstrap)} is not the site's ${short(INSTANCE.bootstrap)}`);
if (read('manifest.json').state_tree?.address !== INSTANCE.state) differ.push("manifest state address is not the site's");
console.log(differ.length ? `not served: ${differ.join('; ')}` : 'served');
JS
}
published_record() { # the published site's copy of its record if BRIDGE_INSTANCE pins the escrow it serves, else nothing
# By escrow, not by directory as site() decides: whatever directory an operator works from, that escrow's
# stores are looked for on the published site (bridge-core/config.js PUBLISHED).
local e; e=$(escrow)
node --input-type=module - "$e" <<'JS' || die "could not read the published site from bridge-core/config.js"
const { INSTANCE, PUBLISHED } = await import('./bridge-core/config.js');
if (INSTANCE !== null && process.argv[2] === INSTANCE.escrow.toLowerCase()) console.log(`${PUBLISHED.site}instances/${INSTANCE.name}`);
JS
}
site_name() { # the record the published site serves, instances/<name>, or "no instance"
node --input-type=module -e '
const { INSTANCE } = await import("./bridge-core/config.js");
console.log(INSTANCE === null ? "no instance" : `instances/${INSTANCE.name}`);
' || die "could not read the site's instance from bridge-core/config.js"
}
target() { # one line: the instance and escrow this recipe acts on, and whether the published site serves them
local e s; e=$(escrow); s=$(site)
if [ "$s" = served ]; then
echo "target: $BRIDGE_INSTANCE, escrow $e (the published site's current instance)"
else
echo "target: $BRIDGE_INSTANCE, escrow $e (${s}; the published site serves only $(site_name))"
fi
}
claim_refused() { # after assemble.py claim fails: identify the escrow and the failed gate
gate "$BRIDGE_WORK/claim.assemble.json"
die "assemble.py claim refused for escrow $(escrow) ($BRIDGE_INSTANCE); run just status <deposit number> ($BRIDGE_WORK/claim.assemble.json)"
}
esplora() { curl -fsS --retry 3 "$LIQUID_ESPLORA/$1" || die "Esplora GET $1 failed"; }
outspend() { # "<txid> <vin> <confirmed>" of the spend of output $2 (default 0) of $1; nothing if unspent
local o; o=$(esplora "tx/$1/outspend/${2:-0}")
python3 -c '
import json, re, sys
o = json.loads(sys.argv[1])
if type(o.get("spent")) is not bool: # a reply without the flag must not read as unspent
sys.exit("Esplora supplied no spent flag")
if o["spent"]:
txid, vin = o.get("txid"), o.get("vin")
if not isinstance(txid, str) or not re.fullmatch(r"[0-9a-f]{64}", txid) or type(vin) is not int or vin < 0:
sys.exit("Esplora supplied an invalid outspend txid or input index")
print(txid, vin, str(o.get("status", {}).get("confirmed", False)).lower())
' "$o"
}
check_funding() { # the asset's issuance input must still exist when the escrow is deployed
local funding txid vout spent asset f
need "$DEPLOY_WORK/escrow-params.json" "run: just deploy-params"
need "$DEPLOY_WORK/asset.json" "run: just deploy-asset"
funding=$(jget "$DEPLOY_WORK/escrow-params.json" funding) || die "cannot read the funding outpoint"
[[ "$funding" =~ ^[0-9a-f]{64}:(0|[1-9][0-9]*)$ ]] || die "invalid funding outpoint in escrow parameters"
[ "$funding" = "$(jget "$DEPLOY_WORK/asset.json" result funding)" ] \
|| die "escrow parameters and asset report name different funding outpoints"
asset=$(jget "$DEPLOY_WORK/escrow-params.json" bridgeAsset)
[ "$asset" = "$(jget "$DEPLOY_WORK/asset.json" result bridge_asset_internal)" ] \
|| die "escrow parameters and asset report name different assets"
txid=${funding%:*}; vout=${funding#*:}
confirmed "$txid" || die "funding transaction $txid is not confirmed"
# Esplora reports any output index it has never seen, existing or not, as unspent.
f=$(tx_file "$txid") || die "could not fetch funding transaction $txid"
tx_report output "$f" "$vout" > /dev/null || die "could not confirm that funding transaction $txid has an output $vout"
spent=$(outspend "$txid" "$vout") || die "could not check whether funding output $funding is unspent"
[ -z "$spent" ] || die "funding output $funding is spent by ${spent%% *}; do not deploy this escrow"
}
check_deploy_gas() {
python3 - "$DEPLOY_WORK/simulation.json" \
"$DEPLOY_WORK/broadcast/DeployBridgeEscrow.s.sol/11155111/dry-run/run-latest.json" <<'PY'
import json
import os
import sys
import urllib.request
simulation = json.load(open(sys.argv[1]))
run = json.load(open(sys.argv[2]))
transactions = run.get("transactions", [])
if len(transactions) != 1:
sys.exit("the dry run must contain exactly one escrow deployment")
transaction = transactions[0]
if transaction.get("contractAddress", "").lower() != simulation["escrow"].lower():
sys.exit("the dry-run transaction does not create the simulated escrow")
call = transaction["transaction"]
limit = int(call["gas"], 16)
request_body = json.dumps({
"jsonrpc": "2.0", "id": 1, "method": "eth_estimateGas",
"params": [{"from": call["from"], "data": call["input"]}],
}).encode()
try: # Request too: its error for a malformed URL would print the URL, and any key in it
request = urllib.request.Request(
os.environ["SEPOLIA_EXECUTION_RPC"], request_body,
{"Content-Type": "application/json"},
)
with urllib.request.urlopen(request, timeout=30) as response:
result = json.load(response)
required = int(result["result"], 16)
except (OSError, KeyError, ValueError, TypeError):
sys.exit("Sepolia eth_estimateGas failed; deployment gas is not verified")
if required <= 0 or limit * 85 < required * 100:
sys.exit(f"transaction limit {limit:,} has less than 15% headroom over Sepolia's {required:,} gas estimate")
print(f"Sepolia deployment gas: {required:,} estimated, {limit:,} limit")
PY
}
anchor_store() { # the anchor's starting store, rebuilt offline from the anchor inputs, into DEPLOY_WORK/anchor.store.json
local head lc
need "$DEPLOY_WORK/anchor-inputs.cbor" "run: just deploy-anchor"
need "$DEPLOY_WORK/anchor.json" "run: just deploy-anchor"
head=$(jget "$DEPLOY_WORK/anchor.json" anchor slot); lc=$(jget "$DEPLOY_WORK/anchor.json" anchor lc)
rm -f "$DEPLOY_WORK/anchor.store.json" "$DEPLOY_WORK/anchor.reconstruct.json" "$DEPLOY_WORK/anchor.check-store.json"
"$BRIDGE_HOST_BIN" reconstruct --head "$head" --lc "$lc" --source "$DEPLOY_WORK/anchor-inputs.cbor" --offline \
--out "$DEPLOY_WORK/anchor.store.json" > "$DEPLOY_WORK/anchor.reconstruct.json" \
|| die "sg16-bridge-host reconstruct did not rebuild the store of anchor slot $head from $DEPLOY_WORK/anchor-inputs.cbor"
"$BRIDGE_HOST_BIN" check-store "$DEPLOY_WORK/anchor.store.json" --lc "$lc" > "$DEPLOY_WORK/anchor.check-store.json" \
|| die "$DEPLOY_WORK/anchor.store.json does not hash to the anchor lc $lc"
[ "$(jget "$DEPLOY_WORK/anchor.check-store.json" head)" = "$head" ] \
|| die "$DEPLOY_WORK/anchor.store.json is not at the anchor slot $head"
}
confirmed() {
local status
status=$(esplora "tx/$1/status") || return 1
[ "$(jget - confirmed <<< "$status")" = true ]
}
gate() { python3 -c '
import json, sys
try:
doc = json.load(open(sys.argv[1]))
except (OSError, ValueError):
print("no JSON gate report; see the preceding error", file=sys.stderr)
else:
print("failed gate:", doc.get("failed_gate", "not named"), file=sys.stderr)
' "$1"; }
check_artifact() {
need "$BRIDGE_WORK/proof.json" "run: just prove"
need "$BRIDGE_WORK/state.json" "run: just recover"
rm -f "$BRIDGE_WORK/check-artifact.json"
if ! python3 "$ASSEMBLE_PY" check-artifact --report "$BRIDGE_WORK/check-artifact.json" \
--instance "$BRIDGE_INSTANCE/instance.json" --artifact "$BRIDGE_WORK/proof.json" \
--state "$BRIDGE_WORK/state.json" > "$BRIDGE_WORK/check-artifact.log"; then
gate "$BRIDGE_WORK/check-artifact.json"
die "the proof does not fit the instance or the state ($BRIDGE_WORK/check-artifact.json; details in $BRIDGE_WORK/check-artifact.log)"
fi
proof_block=$(jget "$BRIDGE_WORK/check-artifact.json" result exec_block) \
|| die "$BRIDGE_WORK/check-artifact.json does not name a proved execution block"
[[ "$proof_block" =~ ^[0-9]+$ ]] || die "$BRIDGE_WORK/check-artifact.json has an invalid proved execution block"
echo "proof accepted: proves through Sepolia block $proof_block"
}
proof_bundle() {
need "$BRIDGE_WORK/proof.json" "run: just prove"
need "$BRIDGE_WORK/state.json" "run: just recover"
need "$BRIDGE_WORK/proof.store.json" "the prover must finish and write its successor store"
rm -f "$BRIDGE_WORK/proof.browser.json" "$BRIDGE_WORK/proof-bundle.json"
if ! python3 "$ASSEMBLE_PY" export-proof --report "$BRIDGE_WORK/proof-bundle.json" \
--instance "$BRIDGE_INSTANCE/instance.json" --manifest "$BRIDGE_INSTANCE/manifest.json" \
--artifact "$BRIDGE_WORK/proof.json" --state "$BRIDGE_WORK/state.json" \
--store "$BRIDGE_WORK/proof.store.json" --out "$BRIDGE_WORK/proof.browser.json" \
> "$BRIDGE_WORK/proof-bundle.log"; then
gate "$BRIDGE_WORK/proof-bundle.json"
die "proof bundle was not exported; see $BRIDGE_WORK/proof-bundle.log. Keep proof.json and retry with just proof-bundle"
fi
need "$BRIDGE_WORK/proof.browser.json" "export did not write a browser bundle"
[ "$(jget "$BRIDGE_WORK/proof-bundle.json" ok)" = true ] || die "proof bundle export did not pass its gates"
[ "$(jget "$BRIDGE_WORK/proof.browser.json" schema)" = zk-bridge/browser-proof ] || die "invalid proof bundle"
proof_block=$(jget "$BRIDGE_WORK/proof-bundle.json" result exec_block) \
|| die "proof bundle report does not name a proved execution block"
[[ "$proof_block" =~ ^[0-9]+$ ]] || die "proof bundle report has an invalid proved execution block"
echo "proof accepted: proves through Sepolia block $proof_block"
echo "upload $BRIDGE_WORK/proof.browser.json in the website update panel"
echo "if claims change the state before upload, run just proof-bundle again; this reuses the proof"
echo "optional, after the browser-built update confirms: just stage-generated to prepare its store for publication"
}
check_inputs() { # refuse inputs.cbor unless it starts from the recovered state and reads this escrow's slot 0
need "$BRIDGE_WORK/inputs.cbor" "run: just inputs"
need "$BRIDGE_WORK/state.json" "run: just inputs"
"$BRIDGE_HOST_BIN" anchor "$BRIDGE_WORK/inputs.cbor" > "$BRIDGE_WORK/inputs.anchor.json" \
|| die "$BRIDGE_WORK/inputs.cbor does not decode as proof inputs; run just inputs"
# A proof takes hours; inputs left from an earlier state or another instance would only be refused after it.
python3 - "$BRIDGE_WORK/inputs.anchor.json" "$BRIDGE_WORK/state.json" "$(escrow)" <<'PY' \
|| die "$BRIDGE_WORK/inputs.cbor is not for the recovered state of $BRIDGE_INSTANCE; run just inputs again"
import json, sys
sys.path.insert(0, "tools/sg16/bridge-guest/testnet")
import pins
summary, state, escrow = json.load(open(sys.argv[1])), json.load(open(sys.argv[2])), sys.argv[3]
hx = lambda h: str(h).lower().removeprefix("0x")
a, net, storage = summary["anchor"], summary["network"], summary["storage"]
bad = []
if (a["slot"], hx(a["lc"])) != (state["head"], hx(state["lc"])):
bad.append(f"they start at slot {a['slot']}, lc {hx(a['lc'])}; the state is at slot {state['head']}, lc {hx(state['lc'])}")
if not net["is_sepolia"] or hx(net["config"]) != pins.SEPOLIA_CONFIG:
bad.append(f"their network (config {hx(net['config'])}) is not Sepolia's, pins.py SEPOLIA_CONFIG")
reads = [(hx(c["address"]), [int(s["key"], 16) for s in c["slots"]]) for c in storage]
if reads != [(hx(escrow), [pins.SLOT_KEY])]:
bad.append(f"they read {reads}, not slot {pins.SLOT_KEY} of escrow {escrow} alone")
if bad:
sys.exit("the inputs do not fit:\n " + "\n ".join(bad))
print(f"inputs start from the state at slot {a['slot']} and read escrow {escrow} at finalized slot "
f"{summary['target_finalized_slot']}")
PY
}
tx_report() { # tx_report <command> <arg>...: the sg16-testnet report; a refusal prints its failed gate and fails
need "$TX_BIN" "run: just tools"
local r; r=$("$TX_BIN" "$@") && { printf '%s\n' "$r"; return; }
printf 'sg16-testnet %s refused: %s\n' "$1" "$(jget - failed_gate <<< "$r" 2>/dev/null || printf '%s' "$r")" >&2
return 1
}
txid_of() { # the Elements txid of hex file $1, computed locally by sg16-testnet txid
local r; r=$(tx_report txid "$1") || return 1
jget - txid <<< "$r"
}
check_txid() { # check_txid <file> <txid>: refuse unless the file's bytes are transaction <txid>
local got; got=$(txid_of "$1") || die "$1 does not parse as an Elements transaction"
[ "$got" = "$2" ] || die "$1 holds transaction $got, not $2"
}
tx_file() { # the local copy of transaction $1, fetched from Esplora if missing; its bytes must hash to $1
hex32 "$1" txid
local f="$BRIDGE_WORK/tx/$1.hex"
if [ ! -s "$f" ]; then
mkdir -p "$BRIDGE_WORK/tx"; esplora "tx/$1/hex" > "$f.part"
check_txid "$f.part" "$1"
mv "$f.part" "$f"
fi
check_txid "$f" "$1"
printf '%s\n' "$f"
}
recover_state() { # BRIDGE_WORK/state.json and recover.json from the chain `chain` walked (BRIDGE_WORK/spends.txt)
need "$TX_BIN" "run: just tools"
local spends=() hints=() f failed
while IFS= read -r f; do spends+=(--spend "$f"); done < "$BRIDGE_WORK/spends.txt"
[ -z "$BRIDGE_HINT" ] || hints=(--hint "$BRIDGE_HINT")
[ ! -f "$REBUILT_STORE" ] || hints+=(--hint "$REBUILT_STORE")
recover_with() {
rm -f "$BRIDGE_WORK/state.json" "$BRIDGE_WORK/recover.json"
python3 "$ASSEMBLE_PY" recover --report "$BRIDGE_WORK/recover.json" \
--instance "$BRIDGE_INSTANCE/instance.json" --manifest "$BRIDGE_INSTANCE/manifest.json" \
--bootstrap-tx "$BRIDGE_INSTANCE/bootstrap.hex" ${spends[@]+"${spends[@]}"} "$@" \
--out "$BRIDGE_WORK/state.json" > "$BRIDGE_WORK/recover.log"
}
if ! recover_with ${hints[@]+"${hints[@]}"}; then
failed=$(jget "$BRIDGE_WORK/recover.json" failed_gate)
if [[ "$failed" == 'a hint is the preimage of the recovered lc:'* ]]; then
echo "no published or rebuilt store matches the live head; recovering the record without one (enough to claim)" >&2
if ! recover_with; then gate "$BRIDGE_WORK/recover.json"; die "recover refused without a store hint"; fi
else
gate "$BRIDGE_WORK/recover.json"
rm -f "$BRIDGE_WORK/state.json"
die "recover refused ($BRIDGE_WORK/recover.json)"
fi
fi
python3 - "$BRIDGE_WORK/recover.json" "$REBUILT_STORE" <<'PY'
import json, sys
r, rebuilt = json.load(open(sys.argv[1]))["result"], sys.argv[2]
s, store = r["state"], r["lc_store"]
updates = [txid for txid, kind in r["chain"] if kind == "update"]
print(f"recovered head {s['head']}, {len(s['claimed'])} claimed deposit(s); live state {r['outpoint']}")
if store and r["hint"] == rebuilt:
print(f"store: {store}, local copy hashing to the live lc; publication has not been checked")
elif store:
print(f"store: {store}")
elif r["complete"]:
print("store: none named for this head; its roots are known, but the next proof needs a beacon bootstrap there")
else:
reached = f", which update {updates[-1]} reached without publishing one" if updates else ""
print(f"store: none published for head {s['head']}{reached}")
print("claims work from this record; before an update, run: just reconstruct")
PY
}
stage_live_store() { # stage_live_store <store> <report stem>; recover_state must have checked this store
local store=$1 report=$2 lc
need "$store" "keep the matching generated or reconstructed store"
need "$BRIDGE_HOST_BIN" "run: just prover"
lc=$(jget "$BRIDGE_WORK/state.json" lc)
"$BRIDGE_HOST_BIN" check-store "$store" --lc "$lc" > "$BRIDGE_WORK/check-store.json" \
|| die "$store does not match the confirmed live state; wait for your update to confirm or select its matching store. Keep your proof files"
rm -f "$report.json"
if ! python3 "$ASSEMBLE_PY" stage-recovered --report "$report.json" \
--instance "$BRIDGE_INSTANCE/instance.json" --recovered "$BRIDGE_WORK/recover.json" --store "$store" \
> "$report.log"; then
gate "$report.json"
die "store staging refused ($report.json); nothing was staged"
fi
echo "staged $(jget "$report.json" result store) and its lc_preimages.json entry, naming update" \
"$(jget "$report.json" result entry update)"
RECORD="$(basename "$BRIDGE_INSTANCE")" RECORD_DIR="$BRIDGE_INSTANCE" node --test bridge-core/test/lc-store.test.js
echo "next: review git diff, then commit and push both files; the site serves them after its next deployment"
}
meta() { # meta <file> key=value...: the sidecar that preflight and broadcast read
python3 -c '
import json, sys
f, *kv = sys.argv[1:]
m = dict(dict(state="", new_state=""), **dict(a.split("=", 1) for a in kv))
m["parents"] = [p for p in m.get("parents", "").split(",") if p]
json.dump(m, open(f + ".meta.json", "w"), indent=1)
' "$@"
}
'''
# List the recipes by group.
default:
@{{ quote(just_executable()) }} --justfile {{ quote(justfile()) }} --list --unsorted
# Print the settings in effect. RPC variables are reported as set or unset, never shown.
[group('read')]
config:
#!/usr/bin/env bash
{{ _prelude }}
e=$(escrow)
target
echo "instance $BRIDGE_INSTANCE"
echo "escrow $e"
echo "work dir $BRIDGE_WORK"
echo "hint ${BRIDGE_HINT:-(none)}"
echo "guest ELF $BRIDGE_ELF"
echo "esplora configured"
echo "fee key path ${LIQUID_FEE_KEY:-(unset)}"
r=$(published_record)
if [ -n "$r" ]; then echo "store readback $r (the published site)"
elif [ -n "$STORE_READBACK_URL" ]; then echo "store readback set"
else echo "store readback unset"; fi
for v in SEPOLIA_EXECUTION_RPC SEPOLIA_CONSENSUS_RPC; do
if [ -n "${!v:-}" ]; then echo "$v set"; else echo "$v unset"; fi
done
# Fetch the pinned vendored dependencies (network).
[group('build')]
deps:
python3 tools/bootstrap-deps.py
# Build the assembly tools, the fee wallet and the operator commands.
[group('build')]
tools:
cargo build --release --locked --manifest-path tools/sg16-harness/Cargo.toml
CARGO_TARGET_DIR=tools/sg16/bridge-guest/target/testnet-tx cargo build --release --locked --manifest-path tools/sg16/bridge-guest/testnet/tx/Cargo.toml
CARGO_TARGET_DIR=tools/sg16/bridge-guest/target/testnet-claim cargo build --release --locked --manifest-path tools/sg16/bridge-guest/testnet/claim/Cargo.toml
cargo build --release --locked --manifest-path tools/sg16/liquid-testnet-wallet/Cargo.toml
cargo build --release --locked --manifest-path tools/bridge-operator/Cargo.toml
# Build the prover host without Docker or the SP1 cargo-prove tool.
[group('build')]
host:
SP1_SKIP_PROGRAM_BUILD=true cargo build --release --locked --manifest-path tools/sg16/bridge-guest/Cargo.toml -p sg16-bridge-host
# Install the pinned SP1 CLI if needed, then build the host and guest ELF (BRIDGE_WORK/elf). Proof only.
[group('build')]
prover:
#!/usr/bin/env bash
{{ _prelude }}
for tool in cargo docker go protoc; do
command -v "$tool" >/dev/null || die "install $tool before running just prover (OPERATOR.md, What you need)"
done
docker info >/dev/null 2>&1 || die "start Docker and check that docker info works, then run just prover again"
mkdir -p "$BRIDGE_WORK/elf"
work=$(cd "$BRIDGE_WORK" && pwd)
export PATH="$work/sp1/bin:$HOME/.sp1/bin:$PATH"
sp1_version() {
local version
version=$(cargo prove --version 2>/dev/null) || return 1
# The release binary prints its Git revision rather than the tag.
[[ "$version" =~ (^|[^[:alnum:]])(6\.8\.1|c84ada1)([^[:alnum:]]|$) ]]
}
if ! sp1_version; then
echo "installing SP1 6.8.1 in $work/sp1 (once; pinned source, no system installation)"
cargo install --locked --git https://github.com/succinctlabs/sp1 --rev "$SP1_CLI_REV" \
--root "$work/sp1" --force sp1-cli
sp1_version || die "the installed SP1 CLI does not report 6.8.1; see $work/sp1/bin"
fi
# Build the host first, before Docker can leave root-owned files in the workspace. Helios's pinned ELFs are vendored.
just host
out=$(cd "$BRIDGE_WORK/elf" && pwd)
(cd tools/sg16/bridge-guest/program && cargo prove build --docker --tag v6.8.1 --locked --workspace-directory ../../../.. --output-directory "$out")
echo "built $out/sg16-bridge-program; next: just check-elf"
# Refuse BRIDGE_ELF unless its SHA-256 and program vkey are the instance's pins. No RPC.
[group('build')]
check-elf:
#!/usr/bin/env bash
{{ _prelude }}
need "$BRIDGE_HOST_BIN" "run: just host (or just prover to build the ELF as well)"
need "$BRIDGE_ELF" "run: just prover, or set BRIDGE_ELF"
"$BRIDGE_HOST_BIN" vkey --elf "$BRIDGE_ELF" | python3 -c '
import json, sys
got, pin = json.load(sys.stdin), json.load(open(sys.argv[1]))
norm = lambda h: str(h).lower().removeprefix("0x")
bad = [f"{k}: ELF gives {norm(got[g])}, instance pins {norm(pin[k])}"
for g, k in (("elf_sha256", "GUEST_ELF_SHA256"), ("vkey", "PROGRAM_VKEY")) if norm(got[g]) != norm(pin[k])]
sys.exit("\n".join(bad)) if bad else print("ELF and vkey match", sys.argv[1])
' "$BRIDGE_INSTANCE/instance.json"
# Walk the state output from the bootstrap through every confirmed public spend (Esplora reads).
[group('read')]
chain:
#!/usr/bin/env bash
{{ _prelude }}
target
mkdir -p "$BRIDGE_WORK/tx"
rm -f "$BRIDGE_WORK/spends.txt" "$BRIDGE_WORK/state-tx.path" "$BRIDGE_WORK/state-tx.txid"
boot=$(txid_of "$BRIDGE_INSTANCE/bootstrap.hex") || die "the instance's bootstrap does not parse as an Elements transaction"
# Fetched before it is compared: a failed fetch (rate limit, outage) is not a mismatch.
public=$(esplora "tx/$boot/hex") \
|| die "could not read the bootstrap $boot from Esplora ($LIQUID_ESPLORA); it may be rate-limiting, retry in a minute"
[ "$(printf '%s' "$public" | tr -d '[:space:]')" = "$(tr -d '[:space:]' < "$BRIDGE_INSTANCE/bootstrap.hex")" ] \
|| die "Esplora's transaction $boot differs from $BRIDGE_INSTANCE/bootstrap.hex"
t=$boot; n=0; : > "$BRIDGE_WORK/spends.txt.part"
while :; do
r=$(outspend "$t")
[ -n "$r" ] || break
set -- $r
[ "$2" = 0 ] || die "output 0 of $t is spent by input $2 of $1; a state spend uses input 0"
[ "$3" = true ] || die "$1 spends the state but is unconfirmed; run just wait $1, then this again"
f=$(tx_file "$1")
echo "$f" >> "$BRIDGE_WORK/spends.txt.part"
t=$1; n=$((n + 1))
done
mv "$BRIDGE_WORK/spends.txt.part" "$BRIDGE_WORK/spends.txt"
if [ "$t" = "$boot" ]; then st="$BRIDGE_INSTANCE/bootstrap.hex"; else st="$BRIDGE_WORK/tx/$t.hex"; fi
echo "$st" > "$BRIDGE_WORK/state-tx.path"
echo "$t" > "$BRIDGE_WORK/state-tx.txid"
echo "$n confirmed state spend(s) after bootstrap $boot; the live state is output 0 of $t"
# Print the Elements txid of a transaction hex file, computed locally; with <expect>, refuse any other. No network.
[group('read')]
txid file expect='':
#!/usr/bin/env bash
{{ _prelude }}
need "$file"
if [ -n "$expect" ]; then check_txid "$file" "$expect"; fi
txid_of "$file" || die "$file does not parse as an Elements transaction"
# Rebuild the live state record (BRIDGE_WORK/state.json) from the public chain.
[group('read')]
recover: chain
#!/usr/bin/env bash
{{ _prelude }}
recover_state
# Write the escrow's Deposit events up to the finalized block (needs SEPOLIA_EXECUTION_RPC).
[group('read')]
deposits:
#!/usr/bin/env bash
{{ _prelude }}
need_env SEPOLIA_EXECUTION_RPC "a Sepolia execution RPC serving eth_getLogs and eth_call"
need "$OPERATOR_BIN" "run: just tools"
e=$(escrow)
from=$(jget "$BRIDGE_INSTANCE/instance.json" ESCROW_DEPLOY_BLOCK)
mkdir -p "$BRIDGE_WORK"
rm -f "$BRIDGE_WORK/deposits.report.json"
# bridge-operator reads the URL from the environment, so it stays out of the process list.
"$OPERATOR_BIN" deposits --escrow "$e" --from "$from" --step "$DEPOSIT_LOG_STEP" --pace "$DEPOSIT_LOG_PACE" \
--out "$BRIDGE_WORK/deposits.json" > "$BRIDGE_WORK/deposits.report.json"
echo "$(jget "$BRIDGE_WORK/deposits.report.json" deposits) deposit(s) to escrow $e final through Sepolia block $(jget "$BRIDGE_WORK/deposits.report.json" to_block)"
# Recover the state, read the final deposits, and say which are proven, claimed, and claimable. With a deposit
# number, report its own status; claims may be made in any order.
[group('read')]
status deposit='': recover deposits
#!/usr/bin/env bash
{{ _prelude }}
[[ "$deposit" =~ ^(0|[1-9][0-9]*)?$ ]] || die "deposit must be a deposit number, got '$deposit'"
s=$(site)
python3 - "$BRIDGE_INSTANCE/instance.json" "$BRIDGE_WORK" "$(escrow)" "$s" "$deposit" <<'PY'
import json, os, sys
from pathlib import Path
sys.path.insert(0, "tools/sg16/bridge-guest/testnet")
import pins
inst, work = pins.load(sys.argv[1]), Path(sys.argv[2])
escrow, served, mine = sys.argv[3], sys.argv[4] == "served", sys.argv[5]
read = lambda name: json.loads((work / name).read_text())
state, rec, deps = read("state.json"), read("recover.json")["result"], read("deposits.json")
to_block = read("deposits.report.json")["to_block"]
def recipient(spk): # as assemble.py claim: a deposit commits to the SHA-256 of the scriptPubKey
return pins.sha(bytes.fromhex(spk.removeprefix("0x")))
events = [(u, recipient(r)) for u, r in deps]
domain, acc = bytes.fromhex(inst.deposit_domain), bytes.fromhex(state["acc"])
claimed = set(pins.claimed_indices(state["claimed"]))
if pins.claimed_root(claimed).hex() != state["claimed_root"]:
sys.exit("the recovered claimed set does not match the state's claimed root")
proven = next((k for k in range(len(events), -1, -1)
if pins.accumulator(pins.tree_root(domain, events[:k]), k) == acc), None)
print(f"live state output 0 of {rec['chain'][-1][0]}, head slot {state['head']}, {len(claimed)} claimed")
print(f"proven through Sepolia block {state.get('exec_block', 'none yet (anchor)')}")
print(f"final deposits {len(events)} through Sepolia block {to_block}")
if rec["lc_store"] and rec["hint"] == os.environ["REBUILT_STORE"]:
print(f"store {rec['lc_store']} (rebuilt by just reconstruct, hashes to the live lc; not published)")
else:
print(f"store {rec['lc_store'] or 'none published for this head'}")
if proven is None:
sys.exit("no prefix of the final deposits gives the state's accumulator; the RPC may lag the proven block: run just status again")
if any(k >= proven for k in claimed):
sys.exit("the recovered claimed set names a deposit outside the state's proven accumulator")
print(f"proven {proven}")
claimable = [k for k in range(proven) if k not in claimed]
for k in claimable[:5]:
units, spk = deps[k]
print(f"claimable #{k}: {units} units to script {spk.removeprefix('0x')}")
if len(claimable) > 5:
print(f"claimable ... {len(claimable) - 5} more proven deposit(s)")
if mine:
k = int(mine)
if k >= len(events):
print(f"deposit #{k} not among the {len(events)} deposits to escrow {escrow} final through block {to_block}: "
"wait for Ethereum finality, or check that it was sent to this escrow")
elif k in claimed:
print(f"deposit #{k} claimed")
elif k >= proven:
print(f"deposit #{k} final, not proven yet: a proof update covering it comes before its claim")
else:
print(f"deposit #{k} proven and unclaimed; anyone may claim it now, paying its committed script "
f"with about {os.environ['FEE_CLAIM_SATS']} sats of Liquid fee")
if claimable and served:
target = int(mine) if mine and int(mine) in claimable else claimable[0]
print(f"next: open the published site's Claim a proven deposit panel, enter {target}, choose a confirmed fee coin, "
f"then build and sign with Humid. No CLI claim is needed. Optional fee-key path: just split claim <txid> <vout>, "
f"just broadcast {work}/fee-claim.hex, just wait <its txid>, just claim {target}")
elif claimable:
target = int(mine) if mine and int(mine) in claimable else claimable[0]
print(f"next: claim deposit #{target} with your fee key; the published site does not serve this instance, so it "
f"has no Humid route: just split claim <txid> <vout>, just broadcast {work}/fee-claim.hex, "
f"just wait <its txid>, just claim {target}, just broadcast {work}/claim.signed.hex")
elif len(events) > proven:
if not (rec["complete"] and rec["lc_store"]):
print(f"next: deposits #{proven}..#{len(events) - 1} are final but unproven, and no store is published for the "
f"live head {state['head']}: just reconstruct rebuilds it from public data and checks it against the "
"live lc; then just inputs, just execute, just prove")
else:
print(f"next: deposits #{proven}..#{len(events) - 1} are final but unproven: just inputs, just execute, just prove")
else:
print(f"next: nothing; every final deposit to escrow {escrow} is proven and claimed")
PY
# Build proof inputs from the live head's store: the published one, or the one `just reconstruct` rebuilt. Only the
# sync-committee updates the proof needs are kept; OPERATOR.md "Minimized inputs" gives the direct command for all.
[group('prove')]
inputs: recover
#!/usr/bin/env bash
{{ _prelude }}
head=$(jget "$BRIDGE_WORK/state.json" head)
store=$(jget "$BRIDGE_WORK/recover.json" result lc_store)
[ -n "$store" ] || die "no store for the live head $head: the update that reached it published none. Run just reconstruct," \
"which rebuilds it from public data and checks it against the live lc, then just inputs again"
need_env SEPOLIA_EXECUTION_RPC "a Sepolia execution RPC serving eth_getProof at the finalized block"
need_env SEPOLIA_CONSENSUS_RPC "a Sepolia beacon API serving light-client updates"
need "$BRIDGE_HOST_BIN" "run: just prover"
finality=$(curl -fsS --max-time 30 "${SEPOLIA_CONSENSUS_RPC%/}/eth/v1/beacon/light_client/finality_update" | python3 -c '
import json, sys
try: print(int(json.load(sys.stdin)["data"]["finalized_header"]["beacon"]["slot"]))
except (KeyError, ValueError, TypeError): sys.exit("the beacon RPC gave no finality slot")
') || die "the beacon RPC did not serve a light-client finality update"
[ "$finality" -gt "$head" ] || die "the beacon RPC is behind the Liquid light-client head $head (finality $finality); use a current Sepolia beacon API"
e=$(escrow)
lc=$(jget "$BRIDGE_WORK/state.json" lc)
rm -f "$BRIDGE_WORK/inputs.cbor"
"$BRIDGE_HOST_BIN" inputs --store "$store" --lc "$lc" --escrow "$e" --out "$BRIDGE_WORK/inputs.cbor"
# Recovery, when the update that reached the live head published no store: rebuild that store from public data (the
# beacon node at SEPOLIA_CONSENSUS_RPC, the instance's published stores and any store or inputs <sources> given,
# paths without spaces), check that it hashes to the lc read from Liquid, and complete the state record with it.
# Writes only under BRIDGE_WORK; without SEPOLIA_CONSENSUS_RPC it uses the files alone.
[group('read')]
reconstruct *sources: recover
#!/usr/bin/env bash
{{ _prelude }}
need "$BRIDGE_HOST_BIN" "run: just prover"
head=$(jget "$BRIDGE_WORK/state.json" head); lc=$(jget "$BRIDGE_WORK/state.json" lc)
store=$(jget "$BRIDGE_WORK/recover.json" result lc_store)
if [ -n "$store" ] && [ "$(jget "$BRIDGE_WORK/recover.json" result hint)" != "$REBUILT_STORE" ]; then
echo "the instance publishes the live head's store, $store; nothing to reconstruct. next: just inputs"
exit 0
fi
if [ -z "$store" ]; then
src=()
for f in "$BRIDGE_INSTANCE"/lc_stores/*.json; do
if [ -f "$f" ]; then src+=(--source "$f"); fi
done
read -ra given <<< "${sources:-}"
for f in ${given[@]+"${given[@]}"}; do need "$f"; src+=(--source "$f"); done
mode=()
if [ -z "${SEPOLIA_CONSENSUS_RPC:-}" ]; then
mode=(--offline)
echo "SEPOLIA_CONSENSUS_RPC is unset: using only the instance's published stores and the given files" >&2
fi
rm -f "$REBUILT_STORE" "$BRIDGE_WORK/reconstruct.json"
if ! "$BRIDGE_HOST_BIN" reconstruct --state "$BRIDGE_WORK/state.json" --out "$REBUILT_STORE" \
${src[@]+"${src[@]}"} ${mode[@]+"${mode[@]}"} > "$BRIDGE_WORK/reconstruct.json"; then
rm -f "$REBUILT_STORE"
die "not recovered: the public data found does not give the store of head $head (refusal above); nothing was" \
"written. Pass an archived store or inputs file of that head's sync-committee period (just reconstruct" \
"<file>...), or use a beacon node that keeps older light-client updates (OPERATOR.md, Recovering a withheld store)"
fi
recover_state
fi
"$BRIDGE_HOST_BIN" check-store "$REBUILT_STORE" --lc "$lc" > "$BRIDGE_WORK/check-store.json" \
|| die "$REBUILT_STORE does not hash to the live lc $lc; not recovered"
[ "$(jget "$BRIDGE_WORK/recover.json" result hint)" = "$REBUILT_STORE" ] && [ "$(jget "$BRIDGE_WORK/state.json" lc)" = "$lc" ] \
|| die "the state record was not completed by $REBUILT_STORE; not recovered"
echo "recovered: $REBUILT_STORE is the store of the live head $head; its roots hash to the live Liquid lc $lc" \
"(state output $(jget "$BRIDGE_WORK/recover.json" result outpoint))"
echo "next: just inputs execute prove; also, to spare later provers this step, just stage-recovered and publish it"
# Execute the guest on the inputs without proving, as a check before `prove`. Minutes of CPU.
[group('prove')]
execute: check-elf
#!/usr/bin/env bash
{{ _prelude }}
check_inputs
"$BRIDGE_HOST_BIN" run --elf "$BRIDGE_ELF" "$BRIDGE_WORK/inputs.cbor" "$BRIDGE_WORK/execute.json"
# Make and SDK-verify a Groth16 proof. Optional profile: low-memory (experimental; not a RAM cap).
[group('prove')]
[confirm("Generate an SP1 Groth16 proof now? It can exceed 16 GB RAM and take minutes to hours. [y/N]")]
prove profile="default": check-elf
#!/usr/bin/env bash
{{ _prelude }}
case {{ quote(profile) }} in
default) ;;
low-memory)
# SP1 6.8.1 worker/config.rs and core/executor/opts.rs. Keep the verifier and arities unchanged.
for setting in NUM_SPLICING_WORKERS SPLICING_BUFFER_SIZE \
NUMBER_OF_SEND_SPLICE_WORKERS_PER_SPLICE SEND_SPLICE_INPUT_BUFFER_SIZE_PER_SPLICE \
NUM_CORE_WORKERS CORE_BUFFER_SIZE NUM_SETUP_WORKERS SETUP_BUFFER_SIZE \
NORMALIZE_PROGRAM_CACHE_SIZE NUM_PREPARE_REDUCE_WORKERS PREPARE_REDUCE_BUFFER_SIZE \
NUM_RECURSION_EXECUTOR_WORKERS RECURSION_EXECUTOR_BUFFER_SIZE \
NUM_RECURSION_PROVER_WORKERS RECURSION_PROVER_BUFFER_SIZE NUM_DEFERRED_WORKERS DEFERRED_BUFFER_SIZE; do
export "SP1_WORKER_$setting=1"
done
export TRACE_CHUNK_SLOTS=2 SHARD_SIZE=4194304 ELEMENT_THRESHOLD=134217728
export RAYON_NUM_THREADS=2 GOMAXPROCS=2 GOGC=50
echo "experimental low-memory profile: smaller shards and queues, fewer workers"
echo "a local trial exceeded 21 GiB; this is not a RAM cap or verified 16 GB support"
;;
*) die "unknown prover profile; use: just prove [low-memory]" ;;
esac
[ ! -e "$BRIDGE_WORK/proof.json" ] || die "$BRIDGE_WORK/proof.json exists; move it aside first, a proof is costly to remake"
check_inputs
if "$BRIDGE_HOST_BIN" run --elf "$BRIDGE_ELF" "$BRIDGE_WORK/inputs.cbor" "$BRIDGE_WORK/proof.json" --prove; then
:
else
status=$?
if [ "$status" -eq 137 ]; then
echo "prover was killed (137), commonly from memory pressure; see OPERATOR.md: Proving with limited RAM" >&2
echo "move proof generation to a larger machine; proof.store.json alone is not a proof" >&2
fi
exit "$status"
fi
proof_bundle
# Export an existing proof for the website using the live state. Claims need a fresh witness, not a new proof.
[group('prove')]
proof-bundle: recover
#!/usr/bin/env bash
{{ _prelude }}
proof_bundle
# Check the proof against the instance pins and the recovered state.
[group('prove')]
check-artifact:
#!/usr/bin/env bash
{{ _prelude }}
check_artifact
# Create the fee key file at LIQUID_FEE_KEY (mode 0600) and print its address. Refuses an existing file.
[group('key')]
fee-key-new:
#!/usr/bin/env bash
{{ _prelude }}
[ -n "$LIQUID_FEE_KEY" ] || die "set LIQUID_FEE_KEY to the new key file's path, under target/ or outside the repository"
[ ! -e "$LIQUID_FEE_KEY" ] || die "$LIQUID_FEE_KEY exists; this never overwrites a key"
if git check-ignore -q -- "$LIQUID_FEE_KEY" 2>/dev/null; then :; else
[ $? -ne 1 ] || die "$LIQUID_FEE_KEY is inside the repository and not ignored by git; put it under target/ or outside"
fi
need "$WALLET_BIN" "run: just tools"
mkdir -p "$(dirname "$LIQUID_FEE_KEY")"
"$WALLET_BIN" new "$LIQUID_FEE_KEY"
# Print the fee key's unconfidential address, to fund with tL-BTC.
[group('key')]
fee-address:
#!/usr/bin/env bash
{{ _prelude }}
need_key
need "$WALLET_BIN" "run: just tools"
"$WALLET_BIN" address "$LIQUID_FEE_KEY"
# Split a fee slot for an update or a claim from output <vout> of your transaction <txid>. Signs; sends nothing.
[group('sign')]
split kind txid vout:
#!/usr/bin/env bash
{{ _prelude }}
case "$kind" in
update) slot=$FEE_UPDATE_SATS ;;
claim) slot=$FEE_CLAIM_SATS ;;
*) die "kind is update or claim, got '$kind'" ;;
esac
hex32 "$txid" txid
[[ "$vout" =~ ^[0-9]+$ ]] || die "vout must be an output index, got '$vout'"
need_key
need "$WALLET_BIN" "run: just tools"
prev=$(tx_file "$txid")
out="$BRIDGE_WORK/fee-$kind.hex"
[ ! -e "$out" ] || echo "note: replacing $out; a copy of each split stays in $BRIDGE_WORK/tx/" >&2
rm -f "$out" "$out.meta.json"
r=$("$WALLET_BIN" split "$LIQUID_FEE_KEY" "$prev" "$vout" "$slot" "$SPLIT_FEE_SATS" "$out")
echo "$r"
id=$(echo "$r" | jget - txid)
check_txid "$out" "$id"
cp "$out" "$BRIDGE_WORK/tx/$id.hex"
meta "$out" kind=split txid="$id" parents="$txid" source_vout="$vout"
echo "next: just broadcast $out, then just wait $id; its change is output 1"
# Build, sign and verify the Liquid update from the checked proof and freshly recovered state. Sends nothing.
[group('sign')]
update: recover
#!/usr/bin/env bash
{{ _prelude }}
need_key
need "$WALLET_BIN" "run: just tools"
need "$BRIDGE_WORK/proof.json" "run: just prove"
need "$BRIDGE_WORK/fee-update.hex.meta.json" "run: just split update <txid> <vout>"
w=$BRIDGE_WORK; st=$(cat "$w/state-tx.path"); st_id=$(cat "$w/state-tx.txid"); fee_id=$(jget "$w/fee-update.hex.meta.json" txid)
check_txid "$w/fee-update.hex" "$fee_id"
rm -f "$w/update.hex" "$w/update.signed.hex" "$w/update.signed.hex.meta.json" "$w/update.new_state.json" "$w/update.assemble.json"
if ! python3 "$ASSEMBLE_PY" update --report "$w/update.assemble.json" \
--instance "$BRIDGE_INSTANCE/instance.json" --manifest "$BRIDGE_INSTANCE/manifest.json" \
--artifact "$w/proof.json" --state "$w/state.json" --state-tx "$st" --fee-tx "$w/fee-update.hex" --fee-vout 0 \
--out "$w/update.hex" > "$w/update.assemble.log"; then
gate "$w/update.assemble.json"
die "assemble.py update refused ($w/update.assemble.json)"
fi
r=$("$WALLET_BIN" sign "$LIQUID_FEE_KEY" "$w/update.hex" 1 "$w/fee-update.hex" 0 "$w/update.signed.hex")
id=$(echo "$r" | jget - txid)
check_txid "$w/update.signed.hex" "$id"
"$TX_BIN" verify "$w/update.signed.hex" 0 "$st" "$w/fee-update.hex" > "$w/update.verify.json" \
|| { gate "$w/update.verify.json"; die "the signed update does not verify"; }
meta "$w/update.signed.hex" kind=update txid="$id" state="$st_id" parents="$st_id,$fee_id" new_state="$w/update.new_state.json"
echo "signed update $id to head $(jget "$w/update.new_state.json" head). Do not broadcast it yet:"
echo "next: just stage-store, publish the store and lc_preimages.json, then just broadcast $w/update.signed.hex (it reads both back from the published site first)"
# Refuse a Humid route before any network read unless the published site serves BRIDGE_INSTANCE: its Humid panels
# load only that instance's updates and claims.
_humid-site route:
#!/usr/bin/env bash
{{ _prelude }}
e=$(escrow); s=$(site)
if [ "$s" = served ]; then exit 0; fi
case "$route" in
claim) other="claim with your own fee key; just status prints those steps" ;;
update) other="sign the update with your own fee key (just update) and publish its store at your own HTTPS URL" ;;
*) die "unknown Humid route $route" ;;
esac
printf '%s\n' "$BRIDGE_INSTANCE (escrow $e) is $s. The published site serves only $(site_name)," \
"and its Humid $route panel refuses any other instance's file. For this instance, $other." \
"Keep the same BRIDGE_INSTANCE and give it a work directory of its own:" \
" BRIDGE_INSTANCE=$BRIDGE_INSTANCE BRIDGE_WORK=target/takeover-$(basename "$BRIDGE_INSTANCE") just status" >&2
die "no Humid $route route for $BRIDGE_INSTANCE; nothing was fetched or built"
# Build an unsigned update against one confirmed, open Humid fee coin. Humid signs and sends it in the site.
[group('assemble')]
update-unsigned txid vout: (_humid-site "update") recover
#!/usr/bin/env bash
{{ _prelude }}
hex32 "$txid" 'Humid fee txid'
[[ "$vout" =~ ^(0|[1-9][0-9]*)$ ]] || die 'Humid fee vout must be a nonnegative integer'
check_artifact
w=$BRIDGE_WORK; st=$(cat "$w/state-tx.path"); st_id=$(cat "$w/state-tx.txid")
fee=$(tx_file "$txid")
confirmed "$txid" || die "Humid fee coin $txid:$vout is not confirmed yet"
[ -z "$(outspend "$txid" "$vout")" ] || die "Humid fee coin $txid:$vout has been spent"
rm -f "$w/update.hex" "$w/update.hex.meta.json" "$w/update.new_state.json" "$w/update.assemble.json"
if ! python3 "$ASSEMBLE_PY" update --report "$w/update.assemble.json" \
--instance "$BRIDGE_INSTANCE/instance.json" --manifest "$BRIDGE_INSTANCE/manifest.json" \
--artifact "$w/proof.json" --state "$w/state.json" --state-tx "$st" --fee-tx "$fee" --fee-vout "$vout" \
--out "$w/update.hex" > "$w/update.assemble.log"; then
gate "$w/update.assemble.json"
die "assemble.py update refused ($w/update.assemble.json)"
fi
node --input-type=module - "$w/update.hex" "$st" "$fee" "$BRIDGE_INSTANCE/manifest.json" <<'JS'
import { readFileSync } from 'node:fs';
import { hexToBytes } from './bridge-core/lib/bytes.js';
import { readUpdate, updatePset } from './bridge-core/lib/update.js';
const [update, state, fee, manifest] = process.argv.slice(2);
const cmr = hexToBytes(JSON.parse(readFileSync(manifest, 'utf8')).update_leaf.cmr);
const raw = hexToBytes(readFileSync(update, 'utf8').trim());
const plan = updatePset(readUpdate(raw, cmr), readFileSync(state, 'utf8').trim(), readFileSync(fee, 'utf8').trim());
console.log(`Humid can review this ${plan.pset.length}-character PSET; it signs only input 1 at ${plan.feeAddress}`);
JS
id=$(txid_of "$w/update.hex")
meta "$w/update.hex" kind=update-unsigned txid="$id" state="$st_id" parents="$st_id,$txid" new_state="$w/update.new_state.json"
echo "unsigned update $id to head $(jget "$w/update.new_state.json" head)"
echo "next: open Send a proof update > Import a transaction built elsewhere; load $w/update.hex and $w/proof.store.json"
echo "publication is optional for Humid; share the store with the next prover, or prepare it for publication with just stage-store unsigned"
# Prepare store files for publication. Optional for Humid; required before just broadcast of a signed update.
[group('publish')]
stage-store mode="signed":
#!/usr/bin/env bash
{{ _prelude }}
case "$mode" in
signed) update_file="$BRIDGE_WORK/update.signed.hex"; expected_kind=update ;;
unsigned) update_file="$BRIDGE_WORK/update.hex"; expected_kind=update-unsigned ;;
*) die 'stage-store mode must be signed or unsigned' ;;
esac
need "$update_file.meta.json" "run: just update or just update-unsigned <Humid fee txid> <vout>"
[ "$(jget "$update_file.meta.json" kind)" = "$expected_kind" ] || die "$update_file.meta.json is not a $mode update record"
check_txid "$update_file" "$(jget "$update_file.meta.json" txid)"
need "$BRIDGE_WORK/proof.json" "just prove writes it"
need "$BRIDGE_WORK/state.json" "just recover writes the spent state"
need "$BRIDGE_WORK/proof.store.json" "just prove writes it beside proof.json"
need "$TX_BIN" "run: just tools"
target
spends=$(tx_report input "$update_file" 0) || die "$update_file has no readable input 0"
creates=$(tx_report output "$update_file" 0) || die "$update_file has no readable output 0"
# Nothing is written into the instance until the update, its new state, the proof and the store are all shown to
# be this instance's: a work directory once used for another BRIDGE_INSTANCE must not pollute this record.
python3 - "$BRIDGE_INSTANCE" "$BRIDGE_WORK" "$update_file" "$update_file.meta.json" \
"$(jget - previous_output <<< "$spends")" "$(jget - script_pubkey <<< "$creates")" <<'PY'
import json, sys
from pathlib import Path
sys.path.insert(0, "tools/sg16/bridge-guest/testnet")
import assemble
inst, work, update_file = Path(sys.argv[1]), Path(sys.argv[2]), Path(sys.argv[3])
meta = json.loads(Path(sys.argv[4]).read_text())
# The outpoint input 0 spends and the script of output 0, as sg16-testnet input and output read them.
spends, creates = sys.argv[5], sys.argv[6]
norm = lambda h: str(h).lower().removeprefix("0x")
other = (f". These files do not describe one update for {inst}; nothing was staged. Keep proof.json and proof.store.json. "
"For a browser-built update, use just stage-generated after it confirms. For a CLI-built update, "
"check the instance and rebuild the transaction with just update or just update-unsigned <fee txid> <vout>")
if Path(meta["new_state"]).resolve() != (work / "update.new_state.json").resolve():
sys.exit(f"{sys.argv[4]} names new state {meta['new_state']}, not {work / 'update.new_state.json'}{other}")
new = json.loads((work / "update.new_state.json").read_text())
# The instance and its manifest, the proof's public values (escrow, successor) and the new state record, with
# assemble.py's own gates; pairing is left to the update recipe, which ran it before writing this update.
g = assemble.Gates()
try:
pinned = assemble.instance(g, inst / "instance.json")
m = assemble.load_manifest(inst / "manifest.json")
assemble.manifest_gates(g, m, pinned)
o = assemble.check_artifact(g, json.loads((work / "proof.json").read_text()), assemble.sp1_keys(g, pinned), pinned,
state=json.loads((work / "state.json").read_text()), pairing=False)["outputs"]
rec = assemble.state_record(int(new["head"]), assemble.b32(new["header"]), assemble.b32(new["committee"]),
assemble.b32(new["acc"]), new["claimed"], exec_root=norm(new["exec_root"]),
exec_block=new["exec_block"])
g.need("update.new_state.json recomputes its lc and state hash", rec == new, {k: new.get(k) for k in rec if rec[k] != new.get(k)})
g.need("the proof's successor = update.new_state.json: head, lc, accumulator, execution root and block",
(o["new_head"], o["new_lc"].hex(), o["slot_value"].hex(), o["exec_root"].hex(), o["exec_block"])
== (new["head"], new["lc"], new["acc"], new["exec_root"], new["exec_block"]),
f"proof head {o['new_head']}, block {o['exec_block']}; update head {new['head']}, block {new['exec_block']}")
tree = assemble.tool_ok(g, "state tree of update.new_state.json under this instance's manifest", assemble.TX_TOOL,
"tree", inst / "manifest.json", work / "update.new_state.json")
g.need("the tree's state hash = update.new_state.json", tree["state_hash"] == new["state_hash"], tree["state_hash"])
except (assemble.Gate, KeyError, TypeError, ValueError, AssertionError) as e:
sys.exit(f"{inst} refuses this update: {e!r}{other}")
# The update itself: input 0 spends output 0 of the state its sidecar names, and output 0 is the state UTXO of
# update.new_state.json under this instance's leaves, which commit to its escrow, guest and claim leaf.
if spends != f"{meta['state']}:0" or meta["parents"][:1] != [meta["state"]]:
sys.exit(f"{update_file} input 0 spends {spends}, not output 0 of its sidecar's state {meta['state']}{other}")
if creates != tree["script_pubkey"]:
sys.exit(f"{update_file} output 0 is not {inst}'s state UTXO for update.new_state.json{other}")
raw = (work / "proof.store.json").read_bytes()
store = json.loads(raw)
for k in ("head", "header", "committee", "lc"):
if norm(store[k]) != norm(new[k]):
sys.exit(f"proof.store.json {k} {store[k]} is not the update's {new[k]}{other}")
rel = f"lc_stores/{new['head']}.json"
dst = inst / rel