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:
1 parent
0a0e8299ac
commit
3ddc292dfc
8 files changed
+311
No files matched your search
@@ -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=…¬e=…`); 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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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>¬e=<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¬e=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
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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>
|
||||
@@ -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}/`));
|
||||
Reference in new issue
Block a user