Skip to content

Commit 0b2d40f

Browse files
Add contract-test.html for minimal 500 sats contract experiment
1 parent 5beac91 commit 0b2d40f

1 file changed

Lines changed: 353 additions & 0 deletions

File tree

contract-test.html

Lines changed: 353 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,353 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8">
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
6+
<title>Contract Test - Add 500 Sats</title>
7+
<style>
8+
* { box-sizing: border-box; margin: 0; padding: 0; }
9+
body {
10+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
11+
background: #f6f8fa;
12+
color: #1f2328;
13+
padding: 40px;
14+
max-width: 700px;
15+
margin: 0 auto;
16+
}
17+
h1 { margin-bottom: 8px; }
18+
.meta { color: #656d76; margin-bottom: 24px; }
19+
.card {
20+
background: #fff;
21+
border: 1px solid #d0d7de;
22+
border-radius: 8px;
23+
padding: 20px;
24+
margin-bottom: 16px;
25+
}
26+
.label { color: #656d76; font-size: 12px; text-transform: uppercase; margin-bottom: 8px; }
27+
.entry {
28+
display: flex;
29+
justify-content: space-between;
30+
padding: 12px 0;
31+
border-bottom: 1px solid #eee;
32+
font-family: monospace;
33+
}
34+
.entry:last-child { border-bottom: none; }
35+
.amount { font-weight: 600; }
36+
.total {
37+
font-size: 24px;
38+
font-weight: 600;
39+
text-align: right;
40+
padding: 16px;
41+
background: #dafbe1;
42+
border-radius: 8px;
43+
margin-bottom: 16px;
44+
}
45+
button {
46+
background: #2da44e;
47+
color: white;
48+
border: none;
49+
padding: 12px 24px;
50+
border-radius: 6px;
51+
font-size: 16px;
52+
cursor: pointer;
53+
margin-right: 8px;
54+
margin-bottom: 8px;
55+
}
56+
button:hover { background: #2c974b; }
57+
button:disabled { background: #94d3a2; cursor: not-allowed; }
58+
button.secondary { background: #0969da; }
59+
button.secondary:hover { background: #0860ca; }
60+
button.secondary:disabled { background: #80b8f0; }
61+
.config {
62+
display: grid;
63+
gap: 12px;
64+
margin-bottom: 16px;
65+
}
66+
.config label { display: block; font-size: 14px; color: #656d76; margin-bottom: 4px; }
67+
.config input {
68+
width: 100%;
69+
padding: 8px 12px;
70+
border: 1px solid #d0d7de;
71+
border-radius: 6px;
72+
font-family: monospace;
73+
font-size: 14px;
74+
}
75+
.log {
76+
background: #f6f8fa;
77+
border: 1px solid #d0d7de;
78+
border-radius: 6px;
79+
padding: 12px;
80+
font-family: monospace;
81+
font-size: 13px;
82+
max-height: 200px;
83+
overflow-y: auto;
84+
white-space: pre-wrap;
85+
}
86+
.log-entry { padding: 4px 0; border-bottom: 1px solid #eee; }
87+
.log-entry:last-child { border-bottom: none; }
88+
.log-entry.error { color: #cf222e; }
89+
.log-entry.success { color: #1a7f37; }
90+
</style>
91+
</head>
92+
<body>
93+
<h1>Contract Test</h1>
94+
<p class="meta">Minimal client-side contract: Add 500 sats to webledger</p>
95+
96+
<div id="app">Loading...</div>
97+
98+
<script type="module">
99+
import { h, render } from 'https://esm.sh/preact@10.19.3';
100+
import { useState, useEffect } from 'https://esm.sh/preact@10.19.3/hooks';
101+
import htm from 'https://esm.sh/htm@3.1.1';
102+
import git from 'https://esm.sh/isomorphic-git@1.25.3';
103+
import http from 'https://esm.sh/isomorphic-git@1.25.3/http/web';
104+
import LightningFS from 'https://esm.sh/@isomorphic-git/lightning-fs@4.6.0';
105+
import { schnorr } from 'https://esm.sh/@noble/curves@1.3.0/secp256k1';
106+
import { bytesToHex, hexToBytes } from 'https://esm.sh/@noble/hashes@1.4.0/utils';
107+
import { sha256 } from 'https://esm.sh/@noble/hashes@1.4.0/sha256';
108+
109+
const html = htm.bind(h);
110+
const fs = new LightningFS('nostr-git-browser');
111+
const pfs = fs.promises;
112+
113+
// Config defaults
114+
const DEFAULT_REPO = '20260202';
115+
const DEFAULT_BRANCH = 'gh-pages';
116+
117+
// Sign Nostr event
118+
async function signEvent(event, privkey) {
119+
const pubkey = bytesToHex(schnorr.getPublicKey(privkey));
120+
event.pubkey = pubkey;
121+
const serialized = JSON.stringify([0, pubkey, event.created_at, event.kind, event.tags, event.content]);
122+
event.id = bytesToHex(sha256(new TextEncoder().encode(serialized)));
123+
event.sig = bytesToHex(await schnorr.sign(event.id, privkey));
124+
return event;
125+
}
126+
127+
// Create authenticated HTTP client for git push
128+
function createAuthenticatedHttp(privkey) {
129+
return {
130+
async request({ url, method, headers, body }) {
131+
const event = {
132+
kind: 27235,
133+
created_at: Math.floor(Date.now() / 1000),
134+
tags: [['u', url], ['method', method]],
135+
content: ''
136+
};
137+
const signed = await signEvent(event, privkey);
138+
const token = btoa(JSON.stringify(signed));
139+
140+
let requestBody = body;
141+
if (body) {
142+
if (typeof body[Symbol.asyncIterator] === 'function') {
143+
const chunks = [];
144+
for await (const chunk of body) { chunks.push(chunk); }
145+
const totalLength = chunks.reduce((sum, c) => sum + c.length, 0);
146+
requestBody = new Uint8Array(totalLength);
147+
let offset = 0;
148+
for (const chunk of chunks) { requestBody.set(chunk, offset); offset += chunk.length; }
149+
} else if (Array.isArray(body)) {
150+
const totalLength = body.reduce((sum, c) => sum + c.length, 0);
151+
requestBody = new Uint8Array(totalLength);
152+
let offset = 0;
153+
for (const chunk of body) { requestBody.set(chunk, offset); offset += chunk.length; }
154+
}
155+
}
156+
157+
const res = await fetch(url, {
158+
method,
159+
headers: { ...headers, 'Authorization': 'Nostr ' + token },
160+
body: requestBody
161+
});
162+
163+
const responseBody = new Uint8Array(await res.arrayBuffer());
164+
return {
165+
url: res.url, method,
166+
statusCode: res.status, statusMessage: res.statusText,
167+
headers: Object.fromEntries(res.headers.entries()),
168+
body: (async function* () { yield responseBody; })()
169+
};
170+
}
171+
};
172+
}
173+
174+
// Contract: Add sats to entry
175+
function addSats(state, pubkey, amount = 500) {
176+
const url = `did:nostr:${pubkey}`;
177+
const entry = state.entries.find(e => e.url === url);
178+
if (entry) {
179+
entry.amount = (parseInt(entry.amount) || 0) + amount;
180+
} else {
181+
state.entries.push({ url, amount });
182+
}
183+
state.updated = Math.floor(Date.now() / 1000);
184+
return state;
185+
}
186+
187+
function App() {
188+
const [repoId, setRepoId] = useState(DEFAULT_REPO);
189+
const [cloneUrl, setCloneUrl] = useState('');
190+
const [privkey, setPrivkey] = useState('');
191+
const [state, setState] = useState(null);
192+
const [logs, setLogs] = useState([]);
193+
const [loading, setLoading] = useState(false);
194+
195+
const log = (msg, type = 'info') => {
196+
setLogs(prev => [...prev, { msg, type, time: new Date().toLocaleTimeString() }]);
197+
};
198+
199+
// Load config from localStorage
200+
useEffect(() => {
201+
try {
202+
const saved = JSON.parse(localStorage.getItem('nostr-git-config') || '{}');
203+
if (saved.repos && saved.repos[repoId]) {
204+
setCloneUrl(saved.repos[repoId].cloneUrl || '');
205+
}
206+
const pk = localStorage.getItem('nostr-git-privkey') || '';
207+
setPrivkey(pk);
208+
} catch (e) {}
209+
}, [repoId]);
210+
211+
// Load webledgers.json
212+
const loadState = async () => {
213+
try {
214+
log('Loading webledgers.json...');
215+
const dir = `/sync/${repoId}`;
216+
const content = await pfs.readFile(`${dir}/webledgers.json`, 'utf8');
217+
const data = JSON.parse(content);
218+
setState(data);
219+
log('State loaded', 'success');
220+
} catch (e) {
221+
log(`Error loading state: ${e.message}`, 'error');
222+
}
223+
};
224+
225+
// Apply contract and save
226+
const handleAddSats = async () => {
227+
if (!state) return;
228+
if (!privkey) { log('Private key required', 'error'); return; }
229+
230+
try {
231+
setLoading(true);
232+
const pubkey = bytesToHex(schnorr.getPublicKey(privkey));
233+
log(`Adding 500 sats to did:nostr:${pubkey.slice(0, 8)}...`);
234+
235+
const newState = addSats({ ...state, entries: [...state.entries] }, pubkey, 500);
236+
setState(newState);
237+
238+
// Write to IndexedDB
239+
const dir = `/sync/${repoId}`;
240+
await pfs.writeFile(`${dir}/webledgers.json`, JSON.stringify(newState, null, 2));
241+
log('State written to IndexedDB', 'success');
242+
} catch (e) {
243+
log(`Error: ${e.message}`, 'error');
244+
} finally {
245+
setLoading(false);
246+
}
247+
};
248+
249+
// Commit and push
250+
const handleCommitPush = async () => {
251+
if (!privkey) { log('Private key required', 'error'); return; }
252+
if (!cloneUrl) { log('Clone URL required', 'error'); return; }
253+
254+
try {
255+
setLoading(true);
256+
const dir = `/sync/${repoId}`;
257+
258+
// Stage file
259+
log('Staging webledgers.json...');
260+
await git.add({ fs, dir, filepath: 'webledgers.json' });
261+
262+
// Commit
263+
log('Creating commit...');
264+
const pubkey = bytesToHex(schnorr.getPublicKey(privkey));
265+
await git.commit({
266+
fs, dir,
267+
message: 'contract: add 500 sats',
268+
author: { name: pubkey.slice(0, 8), email: `${pubkey.slice(0, 8)}@nostr` }
269+
});
270+
log('Commit created', 'success');
271+
272+
// Push
273+
log('Pushing to remote...');
274+
const authHttp = createAuthenticatedHttp(privkey);
275+
await git.push({
276+
fs,
277+
http: authHttp,
278+
dir,
279+
url: cloneUrl,
280+
ref: DEFAULT_BRANCH
281+
});
282+
log('Pushed successfully!', 'success');
283+
} catch (e) {
284+
log(`Error: ${e.message}`, 'error');
285+
} finally {
286+
setLoading(false);
287+
}
288+
};
289+
290+
const total = state?.entries?.reduce((sum, e) => sum + (parseInt(e.amount) || 0), 0) || 0;
291+
292+
return html`
293+
<div class="card">
294+
<div class="label">Configuration</div>
295+
<div class="config">
296+
<div>
297+
<label>Repo ID</label>
298+
<input value=${repoId} onInput=${e => setRepoId(e.target.value)} />
299+
</div>
300+
<div>
301+
<label>Clone URL</label>
302+
<input value=${cloneUrl} onInput=${e => setCloneUrl(e.target.value)} placeholder="https://..." />
303+
</div>
304+
<div>
305+
<label>Private Key (hex)</label>
306+
<input type="password" value=${privkey} onInput=${e => setPrivkey(e.target.value)} placeholder="64-char hex" />
307+
</div>
308+
</div>
309+
<button onClick=${loadState} disabled=${loading}>Load State</button>
310+
</div>
311+
312+
${state && html`
313+
<div class="card">
314+
<div class="label">Current State</div>
315+
${state.entries?.map(e => html`
316+
<div class="entry">
317+
<span>${e.url?.slice(0, 30)}...</span>
318+
<span class="amount">${parseInt(e.amount).toLocaleString()} sats</span>
319+
</div>
320+
`)}
321+
</div>
322+
323+
<div class="total">
324+
Total: ${total.toLocaleString()} sats
325+
</div>
326+
327+
<div class="card">
328+
<div class="label">Contract Actions</div>
329+
<button onClick=${handleAddSats} disabled=${loading}>
330+
+ Add 500 sats
331+
</button>
332+
<button class="secondary" onClick=${handleCommitPush} disabled=${loading}>
333+
Commit & Push
334+
</button>
335+
</div>
336+
`}
337+
338+
<div class="card">
339+
<div class="label">Log</div>
340+
<div class="log">
341+
${logs.map(l => html`
342+
<div class="log-entry ${l.type}">[${l.time}] ${l.msg}</div>
343+
`)}
344+
${logs.length === 0 && html`<div class="log-entry">No logs yet</div>`}
345+
</div>
346+
</div>
347+
`;
348+
}
349+
350+
render(html`<${App} />`, document.getElementById('app'));
351+
</script>
352+
</body>
353+
</html>

0 commit comments

Comments
 (0)