ventures/slyng: README, the skill for agents, the font license, and the build tools

tools/build.sh runs on the refresh runner: the tests, then the two screenshots, served by tools/serve.mjs (node
only, so the build needs node, curl and Chrome and nothing else). tools/og.html is the share card it shoots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ww3hgdnVzTeB85eSsuPLuF
This commit is contained in:
teahubxyzandClaude Opus 5.5 committed 2026-10-16 10:42:18 +00:00
1 parent 0a0e8299ac
commit 3ddc292dfc
8 files changed
+311

No files matched your search

+79
View File
@@ -0,0 +1,79 @@
# SLYNG
Sling sats and notes on nostr: https://slyng.yolopreneur.lol/
Aim at someone you follow, pull back for more, let go to send. Sats go as a zap (NIP-57) from your own wallet; the
note goes private and gift-wrapped (NIP-17). A yolo venture on [yolopreneur.lol](https://yolopreneur.lol/),
October 2026. The Y in the name is the slingshot.
## What it is
One static page, no backend, no dependencies. This folder is the site; `tests/` and `tools/` come along.
- `index.html`, `style.css`, `app.js`: the page. The slingshot fills the first screen; below it the story, how it
works, the agent link, plans, the fine print.
- `sling.js`: the slingshot. The pull is a polar coordinate around the fork: its angle picks who on the fence, its
length picks the amount on the ladder (a note, then 21, 100, 210, 1,000, 2,100, 5,000, 10,000, 21,000 sats). Mouse,
touch and keyboard (arrows aim and stretch, Enter lets go). The geometry is exported for the tests.
- `lib/crypto.js`: SHA-256, HMAC, HKDF, secp256k1 (BIP-340 Schnorr and ECDH), ChaCha20, NIP-44 v2, and NIP-04 for
wallets that still need it. Checked against the BIP-340 and NIP-44 test vectors.
- `lib/nostr.js`: bech32 and NIP-19, events, signers (a key in the page, or a NIP-07 extension), a relay pool that
answers NIP-42 AUTH, NIP-05 lookups.
- `lib/zap.js`: lightning addresses, LNURL-pay, zap requests and receipts, and the check that an invoice is for exactly
the amount pulled before anything is paid.
- `lib/wallet.js`: the sender's wallet, WebLN (a browser extension) or Nostr Wallet Connect (NIP-47).
- `lib/dm.js`: private notes, sealed and gift-wrapped (NIP-17, NIP-59).
- `skill.md`, `llms.txt`: for agents. An agent can aim (a link with `#to=…&sats=…&note=…`); only a person lets go.
- `fonts/`: Slyng Condensed and Slyng Text, cut from TeX Gyre Heros Condensed and TeX Gyre Adventor (GUST Font
License; `fonts/MANIFEST-SLYNG.txt` says what changed).
- `tools/devnet.mjs`: a nostr network on localhost for the tests: a relay with optional AUTH, a lightning address
server that publishes zap receipts, and a Nostr Wallet Connect wallet.
## Decisions
- **Static, no server, no Cloudflare costs.** The page talks to nostr relays and to recipients' lightning address
servers from the browser. The Content-Security-Policy allows scripts, styles and fonts from the page only, and
connections to `https:` and `wss:` (plus localhost, for the devnet).
- **Non-custodial.** Sats move from the sender's wallet to the recipient's; SLYNG never holds them. The invoice amount
is read from the BOLT-11 prefix and must match the pull, or nothing is paid.
- **A sling lands whole or not at all.** With sats and a note, the wallet pays first and the note goes only if the
payment did. The stone is in the air for 2.6 seconds, and catching it back cancels everything.
- **Zaps are public, notes are private.** The zap request carries no comment; the note travels separately, gift
wrapped, to the recipient's NIP-17 inbox relays.
- **Demo first.** Until someone signs in, four made-up people stand on the fence and the sats are pretend; nothing
leaves the page. Signing in is a nostr extension (preferred), a key made here, or a pasted nsec.
- **Verify everything.** Every event from a relay is checked (id and signature) before it's used, so a relay can't
swap in someone else's lightning address.
## Run it
```sh
node --test tests/*.test.js # vectors, notes, zaps, the slingshot, and the devnet flow
node tools/serve.mjs # in this folder, then open http://127.0.0.1:8000/
node tools/devnet.mjs --auth # a local relay, lightning address server and wallet; prints test keys
python3 tests/smoke.py # Chromium (Playwright), desktop and phone: the demo, and a live sling via the devnet
tools/build.sh # the tests, then shots/desktop.png and shots/og.png (needs Chrome or Chromium)
```
To sling by hand against the devnet, open `http://127.0.0.1:8000/?relays=ws://127.0.0.1:<port>` and sign in with one
of the keys it printed (paste the `sk` as a secret key).
## Ship it
SLYNG lives where the yolo ventures live: `ventures/slyng/` on the gh-pages branch of teahubxyz/time, served at
https://slyng.yolopreneur.lol/. To ship a change, commit this folder as `ventures/slyng/` to a branch of
teahubxyz/time (say `venture/slyng`) and run Time's refresh workflow (Actions -> refresh) with slug `slyng` and from
`venture/slyng`. It runs `tools/build.sh` on the runner, lands the folder on gh-pages in one commit, and asks Teahub
pages to pull.
Or as a bundle: `tools/bundle.sh` makes `slyng.tgz` and its sha256 for the seed workflow, when the bundle can reach
the runner (a Boring blob download link).
## Next
- Three front doors: slyng.app (notes), slyng.money (sats), slyng.ing (the market), maybe slyng.my for listings
(`slyng.my/oldbike`, with `?via=<npub>` on a repost so the sale splits to whoever brought the buyer).
- The market: stalls for local and digital goods, offers slung at a stall and countered, the deal desk as stall-keeper.
- Files: sling a file and the key to open it.
- Reach: for notes, a longer pull goes further (one person, a group, everyone).
- Remote signers (NIP-46). A Whop app.
@@ -0,0 +1,30 @@
% This is a preliminary version (2006-09-30), barring acceptance from
% the LaTeX Project Team and other feedback, of the GUST Font License.
% (GUST is the Polish TeX Users Group, http://www.gust.org.pl)
%
% For the most recent version of this license see
% http://www.gust.org.pl/fonts/licenses/GUST-FONT-LICENSE.txt
% or
% http://tug.org/fonts/licenses/GUST-FONT-LICENSE.txt
%
% This work may be distributed and/or modified under the conditions
% of the LaTeX Project Public License, either version 1.3c of this
% license or (at your option) any later version.
%
% Please also observe the following clause:
% 1) it is requested, but not legally required, that derived works be
% distributed only after changing the names of the fonts comprising this
% work and given in an accompanying "manifest", and that the
% files comprising the Work, as listed in the manifest, also be given
% new names. Any exceptions to this request are also given in the
% manifest.
%
% We recommend the manifest be given in a separate file named
% MANIFEST-<fontid>.txt, where <fontid> is some unique identification
% of the font family. If a separate "readme" file accompanies the Work,
% we recommend a name of the form README-<fontid>.txt.
%
% The latest version of the LaTeX Project Public License is in
% http://www.latex-project.org/lppl.txt and version 1.3c or later
% is part of all distributions of LaTeX version 2006/05/20 or later.
+13
View File
@@ -0,0 +1,13 @@
The fonts in this folder are derived works of the TeX Gyre fonts by the GUST e-foundry, distributed under the
GUST Font License (GUST-FONT-LICENSE.txt in this folder; it refers to the LaTeX Project Public License 1.3c,
https://www.latex-project.org/lppl.txt).
As the license requests, the derived fonts and their files have new names:
slyng-condensed-bold.woff "Slyng Condensed Bold" from TeX Gyre Heros Cn Bold (texgyreheroscn-bold.otf)
slyng-text-regular.woff "Slyng Text Regular" from TeX Gyre Adventor Regular (texgyreadventor-regular.otf)
slyng-text-bold.woff "Slyng Text Bold" from TeX Gyre Adventor Bold (texgyreadventor-bold.otf)
What changed: the glyph set was cut down to Latin-1 plus common punctuation, the OpenType features to kern, liga,
tnum, lnum and pnum, the name table to the names above, and the files were converted to WOFF. The outlines are
unchanged. Made for SLYNG (https://slyng.yolopreneur.lol/) in October 2026.
+67
View File
@@ -0,0 +1,67 @@
---
name: slyng
description: Sling sats and private notes to someone on nostr, the way SLYNG does. Either make a SLYNG link that a person opens, checks and lets go of, or, with your own nostr key and wallet, send the zap and the note yourself.
---
# SLYNG, for agents
SLYNG (https://slyng.yolopreneur.lol/) is a slingshot for nostr. A person aims at someone they follow, pulls back
for more sats, and lets go: the sats go as a zap (NIP-57) from their own wallet, and the note goes as a private,
gift-wrapped message (NIP-17). An agent can aim. Only a person lets go.
## 1. Make a link (the usual way)
Give the person a link that opens SLYNG aimed at someone, with the note tied on and the amount marked on the band:
```
https://slyng.yolopreneur.lol/#to=<who>&sats=<how many>&note=<what to say>
```
- `to`: an `npub1…`, an `nprofile1…`, a 64-character hex public key, or a NIP-05 name such as `ben@example.com`.
- `sats`: a whole number from 1 to 21,000. The band has rings at 21, 100, 210, 1,000, 2,100, 5,000, 10,000 and
21,000 sats; SLYNG marks the ring closest to what you ask and says so if it isn't exact. Leave it out for a note on
its own.
- `note`: the words, URL-encoded, up to 1,000 characters. Leave it out to send sats on their own.
Example, for 2,100 sats and a thank-you to ben@example.com:
```
https://slyng.yolopreneur.lol/#to=ben%40example.com&sats=2100&note=thanks%20for%20the%20records
```
Nothing is sent when the link opens. The person reads it, pulls back to the marked ring and lets go, or doesn't.
Tell them what the link does in one line when you hand it over.
## 2. Sling by yourself (when you have your own key and wallet)
Only when the person you work for asked you to send sats, and only the amount they named.
1. **Find them.** Fetch the recipient's kind 0 (profile: `lud16` or `lud06`), kind 10050 (relays for private
messages) and kind 10002 (relay list) from a few relays: wss://relay.damus.io, wss://nos.lol,
wss://relay.primal.net. Verify every event's id and signature before you use it; a forged profile with someone
else's lightning address is how sats get stolen.
2. **Get an invoice (NIP-57).** `lud16` `name@domain` means `GET https://domain/.well-known/lnurlp/name`. If the answer
has `allowsNostr: true` and a `nostrPubkey`, sign a kind 9734 zap request with tags
`["relays", …]`, `["amount", "<millisats>"]`, `["lnurl", "<bech32 lnurl of that URL>"]`, `["p", "<their hex key>"]`
and `["client", "slyng"]`, then `GET <callback>?amount=<millisats>&nostr=<the zap request as JSON, URL-encoded>&lnurl=<lnurl>`.
Without `allowsNostr`, call the callback with `amount` alone (a plain lightning payment, no receipt).
3. **Check the invoice.** The amount in the invoice's prefix must equal what you asked for (`lnbc2100n1…` is
210,000 millisats, 210 sats). If it doesn't, pay nothing.
4. **Pay** from your own wallet, for example with Nostr Wallet Connect (NIP-47 `pay_invoice`).
5. **Send the note (NIP-17).** Make a kind 14 message (not signed) with a `["p", "<their key>"]` tag. Seal it: a kind 13
event, signed by you, whose content is the message encrypted to them with NIP-44. Wrap it: a kind 1059 event,
signed by a one-time key, whose content is the seal encrypted to them with NIP-44, with a `["p", "<their key>"]` tag.
Give both the seal and the wrap a `created_at` up to two days in the past. Publish the wrap to their kind 10050
relays, and a second wrap addressed to yourself to your own.
6. **Watch for the catch.** Their lightning address server publishes a kind 9735 receipt that tags them (`p`) and you
(`P`); its `description` is your zap request.
House rules: a sling lands whole or not at all, so pay first and send the note only if the payment went through.
Zaps are public (anyone can see who sent how much to whom); notes are not. Never put anything private in a zap
request's content.
## Files
- https://slyng.yolopreneur.lol/ : the slingshot
- https://slyng.yolopreneur.lol/skill.md : this file
- https://slyng.yolopreneur.lol/llms.txt : the list, for agents
+50
View File
@@ -0,0 +1,50 @@
#!/bin/sh
# tools/build.sh: SLYNG's made files, made in place: the tests, then shots/desktop.png and shots/og.png.
#
# Time's refresh workflow runs this on the runner (teahubxyz/time, Actions -> refresh, slug slyng); it runs anywhere
# else too. Needs node 22+, curl, and Chrome or Chromium: set CHROME=/path/to/chrome if it isn't found, and
# CHROME_FLAGS=--no-sandbox when running as root. The fuller check is tests/smoke.py (Playwright), run by hand.
set -eu
cd "$(dirname "$0")/.."
node --test tests/*.test.js
CHROME=${CHROME:-}
for c in "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" "$(command -v chromium || true)" \
"$(command -v chromium-browser || true)" "$(command -v google-chrome || true)" "$(command -v google-chrome-stable || true)"; do
if [ -z "$CHROME" ] && [ -n "$c" ] && [ -x "$c" ]; then CHROME=$c; fi
done
[ -n "$CHROME" ] || { echo "no Chrome or Chromium found; set CHROME" >&2; exit 1; }
PORT=${PORT:-8793}
node tools/serve.mjs "$PORT" . >/dev/null 2>&1 </dev/null &
SRV=$!
trap 'kill $SRV 2>/dev/null || true' EXIT INT TERM
n=0
until curl -fs -o /dev/null --max-time 2 "http://127.0.0.1:$PORT/"; do
n=$((n + 1)); [ "$n" -lt 40 ] || { echo "the local server on port $PORT never answered" >&2; exit 1; }; sleep 0.5
done
shot() { # shot OUT.png [chrome args] URL
out=$1; shift
prof=$(mktemp -d)
rm -f "$out"
# shellcheck disable=SC2086
"$CHROME" --headless=new --hide-scrollbars --no-first-run --no-default-browser-check --disable-background-networking \
--disable-component-update --user-data-dir="$prof" --virtual-time-budget=6000 --screenshot="$out" \
${CHROME_FLAGS:-} "$@" >"$prof.log" 2>&1 &
pid=$!
n=0
while kill -0 "$pid" 2>/dev/null && [ ! -s "$out" ]; do # some Chromes write the shot and never exit
n=$((n + 1)); [ "$n" -lt 120 ] || break; sleep 0.5
done
sleep 1
kill "$pid" 2>/dev/null || true
wait "$pid" 2>/dev/null || true
[ -s "$out" ] || { echo "Chrome made no screenshot for $out:" >&2; tail -5 "$prof.log" >&2; exit 1; }
rm -rf "$prof" "$prof.log"
}
mkdir -p shots
shot "$PWD/shots/desktop.png" --window-size=1440,900 "http://127.0.0.1:$PORT/"
shot "$PWD/shots/og.png" --window-size=1200,630 "http://127.0.0.1:$PORT/tools/og.html"
echo "built: tests passed, shots/desktop.png and shots/og.png"
+15
View File
@@ -0,0 +1,15 @@
#!/bin/sh
# tools/bundle.sh [OUT]: OUT/slyng.tgz (slyng/ at the top) and its sha256, for Time's seed workflow
# (teahubxyz/time, Actions -> seed), when the bundle can reach the runner (a Boring blob download link).
set -eu
cd "$(dirname "$0")/.."
OUT=${1:-/tmp/slyng-dist}
export COPYFILE_DISABLE=1
rm -rf "$OUT/slyng" "$OUT/slyng.tgz"
mkdir -p "$OUT/slyng"
for f in *; do [ "$f" = "$(basename "$OUT")" ] || cp -R "$f" "$OUT/slyng/"; done
find "$OUT/slyng" \( -name .DS_Store -o -name __pycache__ \) -prune -exec rm -rf {} +
tar -czf "$OUT/slyng.tgz" -C "$OUT" slyng
sum=$( (shasum -a 256 "$OUT/slyng.tgz" 2>/dev/null || sha256sum "$OUT/slyng.tgz") | cut -d' ' -f1)
echo "$sum" > "$OUT/slyng.tgz.sha256"
echo "bundle $OUT/slyng.tgz: $(wc -c < "$OUT/slyng.tgz" | tr -d ' ') bytes, sha256 $sum"
+29
View File
@@ -0,0 +1,29 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>SLYNG share card</title>
<style>
@font-face { font-family: "Slyng Condensed"; src: url("../fonts/slyng-condensed-bold.woff") format("woff"); font-weight: 700; }
@font-face { font-family: "Slyng Text"; src: url("../fonts/slyng-text-regular.woff") format("woff"); font-weight: 400; }
html, body { margin: 0; width: 1200px; height: 630px; overflow: hidden; background: #D8E8F0; color: #15243B; }
.card { position: relative; width: 1200px; height: 630px; box-sizing: border-box; padding: 70px 84px; }
.word { font: 700 300px/0.82 "Slyng Condensed", sans-serif; letter-spacing: 0.02em; display: flex; align-items: flex-end; margin: 26px 0 0; }
.word svg { height: 0.74em; width: auto; margin: 0 0.035em; overflow: visible; }
.motto { font: 400 44px/1.2 "Slyng Text", sans-serif; margin: 46px 0 0; }
.url { position: absolute; right: 84px; bottom: 62px; font: 400 28px "Slyng Text", sans-serif; color: #4A5B73; }
.arc { position: absolute; right: 70px; top: 54px; }
</style>
</head>
<body>
<div class="card">
<svg class="arc" width="330" height="170" viewBox="0 0 330 170" aria-hidden="true">
<path d="M10 160 Q170 -40 300 70" fill="none" stroke="#15243B" stroke-opacity="0.5" stroke-width="5" stroke-dasharray="1 18" stroke-linecap="round"/>
<circle cx="300" cy="70" r="26" fill="#FBFCFD" stroke="#E2412E" stroke-width="6"/>
</svg>
<p class="word">SL<svg viewBox="0 0 64 72"><path d="M32 69V40M10 6C10.5 22 20 35 32 40C44 35 53.5 22 54 6" fill="none" stroke="#8A5A35" stroke-width="9.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M10 6L32 24L54 6" fill="none" stroke="#E2412E" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round"/><circle cx="32" cy="24" r="7" fill="#F0A532" stroke="#A86814" stroke-width="1.5"/></svg>NG</p>
<p class="motto">Sling sats and notes on nostr.</p>
<p class="url">slyng.yolopreneur.lol</p>
</div>
</body>
</html>
+28
View File
@@ -0,0 +1,28 @@
// tools/serve.mjs [port] [folder]: serve the page on 127.0.0.1, for the build's screenshots and for trying it locally.
// Static files only, nothing outside the folder; no dependencies beyond node.
import { createServer } from 'node:http';
import { readFile, stat } from 'node:fs/promises';
import { extname, join, resolve, sep } from 'node:path';
const port = Number(process.argv[2] || 8000);
const root = resolve(process.argv[3] || '.');
const TYPES = {
'.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'text/javascript; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png',
'.woff': 'font/woff', '.woff2': 'font/woff2', '.txt': 'text/plain; charset=utf-8', '.md': 'text/markdown; charset=utf-8',
};
createServer(async (req, res) => {
try {
let file = resolve(join(root, decodeURIComponent(new URL(req.url, 'http://localhost').pathname)));
if (file !== root && !file.startsWith(root + sep)) throw new Error('outside the folder');
if ((await stat(file)).isDirectory()) file = join(file, 'index.html');
const body = await readFile(file);
res.writeHead(200, { 'content-type': TYPES[extname(file)] || 'application/octet-stream', 'cache-control': 'no-store' });
res.end(req.method === 'HEAD' ? undefined : body);
} catch {
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
res.end('not found\n');
}
}).listen(port, '127.0.0.1', () => console.log(`serving ${root} at http://127.0.0.1:${port}/`));