Skip to content

Commit 13d998c

Browse files
compusophyclaude
andcommitted
ci(ios): phase 3 — FULL real onboarding on the simulator via a bearer invite
workflow_dispatch with an inv-… code: the sim opens /?invite=<code>, the real deployed app auto-redeems it into the local device key through the sponsored relay (a REAL mainnet acceptInvite from real Mobile Safari). Page-side signals (.lh_device_key + lh_model_access=credits, set only on redeem success) + the AUTHORITATIVE CI-side proof: the funder's on-chain escrowedOf(address) must DROP (scripts/tab-e2e/escrowed-of.mjs, raw eth_call, selector cast-verified). Phase 2 is skipped on invite runs so the bare app can't pre-mint the device key and muddy the proof. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
1 parent 04c591a commit 13d998c

3 files changed

Lines changed: 94 additions & 3 deletions

File tree

.github/workflows/ios-webkit-e2e.yml

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,15 @@ name: iOS WebKit E2E
2020

2121
on:
2222
workflow_dispatch:
23+
inputs:
24+
invite_code:
25+
description: "OPTIONAL bearer invite (inv-…) — runs phase 3: FULL real onboarding on the sim (auto-redeem via the relay; consumes the invite, real $LH). Mint one: localharness invite create --as claude"
26+
required: false
27+
default: ""
28+
funder:
29+
description: "the invite's funder address (escrowedOf must DROP after the sim accepts)"
30+
required: false
31+
default: "0x63140875e00ca87ffab5110d5927698195bf8561"
2332
push:
2433
paths:
2534
- "web/opfs-worker.js"
@@ -79,6 +88,9 @@ jobs:
7988
node -e "const r=require('./e2e-report-phase1.json'); if (r.ok !== true) { console.error('phase 1 FAILED'); process.exit(1); }"
8089
8190
- name: phase 2 — the real app boots + writes its device key via the broker
91+
# Skipped on a phase-3 run: opening the bare app first would mint the
92+
# device key WITHOUT the invite, muddying the full-onboarding proof.
93+
if: github.event.inputs.invite_code == ''
8294
run: |
8395
xcrun simctl openurl "$UDID" "http://localhost:8080/"
8496
sleep 45 # foreground boot time: wasm init + Host::Other paint + device-key write
@@ -87,3 +99,21 @@ jobs:
8799
test -f e2e-report-phase2.json || { echo "phase 2: no report within 150s"; exit 1; }
88100
cat e2e-report-phase2.json
89101
node -e "const r=require('./e2e-report-phase2.json'); if (r.ok !== true) { console.error('phase 2 FAILED'); process.exit(1); }"
102+
103+
- name: phase 3 — FULL onboarding via a real invite (dispatch-only)
104+
if: github.event.inputs.invite_code != ''
105+
env:
106+
INVITE: ${{ github.event.inputs.invite_code }}
107+
FUNDER: ${{ github.event.inputs.funder }}
108+
run: |
109+
E0=$(node scripts/tab-e2e/escrowed-of.mjs "$FUNDER"); echo "escrowedOf(funder) BEFORE: $E0"
110+
test "$E0" != "0" || { echo "funder has NO escrowed invites — mint one first (localharness invite create)"; exit 1; }
111+
xcrun simctl openurl "$UDID" "http://localhost:8080/?invite=$INVITE"
112+
sleep 60 # boot + auto-redeem: sponsored acceptInvite via the relay (a real mainnet tx)
113+
xcrun simctl openurl "$UDID" "http://localhost:8080/e2e-ios-probe.html?phase=3"
114+
for i in $(seq 1 75); do [ -f e2e-report-phase3.json ] && break; sleep 2; done
115+
test -f e2e-report-phase3.json || { echo "phase 3: no report within 150s"; exit 1; }
116+
cat e2e-report-phase3.json
117+
node -e "const r=require('./e2e-report-phase3.json'); if (r.ok !== true) { console.error('phase 3 page-side FAILED'); process.exit(1); }"
118+
E1=$(node scripts/tab-e2e/escrowed-of.mjs "$FUNDER"); echo "escrowedOf(funder) AFTER: $E1"
119+
node -e "const [a,b]=[BigInt('$E0'),BigInt('$E1')]; if (!(b < a)) { console.error('on-chain escrow did NOT drop — the accept never landed'); process.exit(1); } console.log('on-chain PROOF: escrow dropped by', (a-b).toString(), 'wei — the sim accepted a REAL invite');"

scripts/tab-e2e/e2e-ios-probe.html

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,14 @@
1818
foreground time to boot; this phase polls the SAME ORIGIN's OPFS
1919
for the .lh_device_key the app mints on Host::Other boot -> proof
2020
the real wasm app completed a broker write on real Mobile Safari.
21+
phase=3 — FULL ONBOARDING: the workflow opened /?invite=<code> (a real
22+
InviteFacet bearer invite) — the app auto-redeems it into the
23+
local device key via the sponsored relay (a REAL mainnet accept
24+
from real Mobile Safari). Same-origin success signals polled here:
25+
.lh_device_key in OPFS + localStorage lh_model_access ===
26+
'credits' (set ONLY on redeem success — events/credits.rs). The
27+
AUTHORITATIVE check is CI-side: the funder's on-chain escrowedOf
28+
must DROP (scripts/tab-e2e/escrowed-of.mjs).
2129
-->
2230
<pre id="log" style="font: 12px monospace; white-space: pre-wrap"></pre>
2331
<script>
@@ -71,19 +79,39 @@
7179
return report({ ok: steps.readback === true, steps });
7280
}
7381

74-
// phase 2 — poll for the app-minted device key (>= 64 chars).
82+
// phases 2 + 3 — poll for the app-minted device key (>= 64 chars);
83+
// phase 3 additionally requires the invite-redeem success signal.
7584
const t0 = Date.now();
7685
while (Date.now() - t0 < 90000) {
7786
try {
7887
const fh = await root.getFileHandle('.lh_device_key');
7988
const text = await (await fh.getFile()).text();
8089
if (text.length >= 64) {
81-
return report({ ok: true, steps: { deviceKeyLen: text.length, waitedMs: Date.now() - t0 } });
90+
steps.deviceKeyLen = text.length;
91+
if (phase !== '3') {
92+
return report({ ok: true, steps: { deviceKeyLen: text.length, waitedMs: Date.now() - t0 } });
93+
}
94+
// lh_model_access is set to 'credits' ONLY on a successful
95+
// on-chain invite accept/redeem (events/credits.rs) — the
96+
// same-origin success signal. Keep polling until it lands
97+
// (the sponsored tx takes seconds after the key exists).
98+
steps.modelAccess = localStorage.getItem('lh_model_access') || '';
99+
steps.pendingInviteCleared = !localStorage.getItem('lh_pending_invite');
100+
if (steps.modelAccess === 'credits') {
101+
steps.waitedMs = Date.now() - t0;
102+
return report({ ok: true, steps });
103+
}
82104
}
83105
} catch { /* not yet */ }
84106
await new Promise((r) => setTimeout(r, 2000));
85107
}
86-
return report({ ok: false, steps, err: '.lh_device_key never appeared (>=64 chars) within 90s' });
108+
return report({
109+
ok: false,
110+
steps,
111+
err: phase === '3'
112+
? 'full onboarding incomplete within 90s (need .lh_device_key + lh_model_access=credits)'
113+
: '.lh_device_key never appeared (>=64 chars) within 90s',
114+
});
87115
} catch (e) {
88116
return report({ ok: false, steps, err: String(e) });
89117
}

scripts/tab-e2e/escrowed-of.mjs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
// Read InviteFacet.escrowedOf(funder) via raw JSON-RPC — the CI-side
2+
// on-chain truth for the iOS full-onboarding E2E: when the simulator's app
3+
// accepts the bearer invite, the funder's escrowed total DROPS by the invite
4+
// amount. No cast/foundry needed on the runner.
5+
//
6+
// node scripts/tab-e2e/escrowed-of.mjs <funder-0xaddr> → prints raw wei
7+
//
8+
// Selector 0xe1e6f37c = keccak("escrowedOf(address)")[0..4] (verified with
9+
// `cast sig` 2026-07-07). Diamond + RPC = mainnet (src/registry/chain.rs).
10+
const DIAMOND = '0x8ab4f3a57643410cdf4022cdaf1faeef234f3a77';
11+
const RPC = process.env.LH_RPC || 'https://rpc.tempo.xyz';
12+
13+
const addr = (process.argv[2] || '').toLowerCase().replace(/^0x/, '');
14+
if (!/^[0-9a-f]{40}$/.test(addr)) {
15+
console.error('usage: node escrowed-of.mjs <funder-0xaddress>');
16+
process.exit(2);
17+
}
18+
const res = await fetch(RPC, {
19+
method: 'POST',
20+
headers: { 'content-type': 'application/json' },
21+
body: JSON.stringify({
22+
jsonrpc: '2.0',
23+
id: 1,
24+
method: 'eth_call',
25+
params: [{ to: DIAMOND, data: '0xe1e6f37c' + addr.padStart(64, '0') }, 'latest'],
26+
}),
27+
});
28+
const j = await res.json();
29+
if (j.error) {
30+
console.error('eth_call error:', JSON.stringify(j.error));
31+
process.exit(1);
32+
}
33+
console.log(BigInt(j.result).toString());

0 commit comments

Comments
 (0)