Compare commits

..

175 Commits

Author SHA1 Message Date
fiatjaf
1f2492c9b1 fix multiline handler thing for when we're don't have any stdin. 2025-02-05 20:42:55 -03:00
fiatjaf
d00976a669 curl: assume POST when there is data and no method is specified. 2025-02-05 10:39:30 -03:00
fiatjaf
4392293ed6 curl method and negative make fixes. 2025-02-05 10:22:04 -03:00
fiatjaf
60d1292f80 parse multiline json from input on nak event and nak req, use iterators instead of channels for more efficient stdin parsing. 2025-02-05 09:44:16 -03:00
fiatjaf
6c634d8081 nak curl 2025-02-04 23:20:35 -03:00
fiatjaf
1e353680bc wallet: adapt to single-wallet nip60 mode and implement nutzapping. 2025-02-04 22:38:40 -03:00
fiatjaf
ff8701a3b0 fetch: stop adding kind:0 to all requests when they already have a kind specified. 2025-02-03 15:54:12 -03:00
fiatjaf
ad6b8c4ba5 more mcp stuff. 2025-02-02 00:21:25 -03:00
fiatjaf
dba3f648ad experimental mcp server. 2025-01-31 23:44:03 -03:00
fiatjaf
12a1f1563e wallet pay and fix missing tokens because the program was exiting before they were saved. 2025-01-30 19:59:54 -03:00
fiatjaf
e2dd3ca544 fix -v verbose flag (it was being used by the default --version flag). 2025-01-30 19:58:17 -03:00
fiatjaf
df5ebd3f56 global verbose flag. 2025-01-30 16:06:16 -03:00
fiatjaf
81571c6952 global bold/italic helpers. 2025-01-30 16:05:51 -03:00
fiatjaf
6e43a6b733 reword NIP-XX to nipXX everywhere. 2025-01-29 19:13:30 -03:00
fiatjaf
943e8835f9 nak wallet 2025-01-28 23:50:09 -03:00
fiatjaf
6b659c1552 fix @mleku unnecessary newlines. 2025-01-28 23:50:09 -03:00
mleku
aa53f2cd60 show env var in help, reset terminal mode correctly 2025-01-21 16:14:59 -03:00
fiatjaf
5509095277 nak outbox 2025-01-18 18:40:19 -03:00
fiatjaf
a3ef9b45de update go-nostr to solve some websocket things like a stupid limit to the websocket message. 2025-01-18 15:45:08 -03:00
fiatjaf
df20a3241a update go-nostr to fix url path normalization (@pablof7z's @primalhq NWC bug). 2025-01-16 21:46:43 -03:00
fiatjaf
53a2451303 update go-nostr and adjust user-agent stuff.
this changes the websocket library we were using.
2025-01-16 16:02:08 -03:00
fiatjaf
2d992f235e bunker: fix MarshalIndent() is not allowed to have a prefix on json-iterator. 2024-12-13 23:27:08 -03:00
franzap
7675929056 Add zapstore.yaml 2024-12-11 19:02:56 -03:00
fiatjaf
7f608588a2 improve count and support hyperloglog aggregation. 2024-12-10 23:28:36 -03:00
fiatjaf
fd5cd55f6f replace encoding/json with json-iterator everywhere so we get rid of HTML encoding and maybe be faster. 2024-12-03 00:43:52 -03:00
redraw
932361fe8f fix(decode): handle event id flag 2024-12-02 10:25:38 -03:00
redraw
11ae7bc4d3 add test ExampleDecodePubkey 2024-12-02 09:11:19 -03:00
redraw
7033bfee19 fix(decode): handle pubkey flag 2024-12-02 09:11:19 -03:00
fiatjaf
f425097c5a allow filters with long tags (the 1-char restriction is only a convention, not a rule).
fixes https://github.com/fiatjaf/nak/issues/44
2024-11-26 12:05:40 -03:00
fiatjaf
dd0ef2ca64 relay management examples on help. 2024-11-25 12:50:47 -03:00
Yasuhiro Matsumoto
491a094e07 close ch 2024-11-23 08:14:13 -03:00
fiatjaf
9d619ddf00 remove note1 encoding. 2024-11-19 07:47:11 -03:00
fiatjaf
5d32739573 update go-nostr again, apparently this was necessary. 2024-11-12 18:46:38 -03:00
fiatjaf
a187e448f2 get rid of some of the HTML escaping that plagues golang json. 2024-11-11 23:09:15 -03:00
fiatjaf
9a9e96a829 support $NOSTR_CLIENT_KEY environment variable for --connect-as 2024-11-11 22:33:17 -03:00
fiatjaf
4c6181d649 update go-nostr so all bunkers are nip44 maximalists. 2024-11-01 08:44:15 -03:00
fiatjaf
71b106fd45 update go-nostr so we always encrypt nip46 messages with nip44. 2024-10-30 10:39:09 -03:00
Yasuhiro Matsumoto
40892c1228 build arm binary for Raspberry Pi 32bit 2024-10-30 10:33:31 -03:00
fiatjaf
847f8aaa69 remove duplicated password decryption prompts by returning the bare key together with the Keyer when it is given. 2024-10-29 21:11:15 -03:00
fiatjaf
134d1225d6 nak event: presence of key flags indicates the need to resign a given event.
fixes https://github.com/fiatjaf/nak/issues/41
2024-10-29 13:33:35 -03:00
fiatjaf
464766a836 allow "=" in tag value.
fixes https://github.com/fiatjaf/nak/issues/40
2024-10-27 11:01:30 -03:00
fiatjaf
ea53eca74f update go-nostr for nip44-on-nip46 fixes. 2024-10-27 09:56:49 -03:00
fiatjaf
38ed370c59 slightly improve verify error message. 2024-10-11 17:59:08 -03:00
fiatjaf
5b04bc4859 nak key public --with-parity 2024-10-08 09:08:50 -03:00
fiatjaf
2988c71ccb nak/b and nak/s user-agents. 2024-09-26 22:17:31 -03:00
fiatjaf
d7c0ff2bb7 update go-nostr keyer interface and make req --auth work again. 2024-09-22 19:21:41 -03:00
fiatjaf
43fe41df5d use log() function instead of fmt.Fprintf(os.Stderr) in some places. 2024-09-22 19:04:21 -03:00
fiatjaf
3215726417 use stdout() function instead of fmt.Println() in some places. 2024-09-21 12:02:09 -03:00
fiatjaf
a4886dc445 nak encrypt and nak decrypt: nip44 with option to do nip04.
closes https://github.com/fiatjaf/nak/issues/36
2024-09-17 11:33:02 -03:00
fiatjaf
dae7eba8ca use keyer.Keyer in most places instead of raw bunkers and plaintext keys, simplifies the code a little at the cost of some abstraction but I think it's strictly good this time. 2024-09-17 11:33:02 -03:00
fiatjaf
2b5f3355bc use a single global sdk.System and its Pool. 2024-09-17 11:33:02 -03:00
fiatjaf
bd5ca27661 github.ref->github.ref_name as version variable. 2024-09-15 09:04:52 -03:00
fiatjaf
9d02301b2d support --version using -X 2024-09-15 08:57:53 -03:00
arkinox
9bbc87b27a specify how ; can separate multiple tag values 2024-09-10 19:13:25 -03:00
fiatjaf
88a07a3504 update go-nostr and nostr-sdk to fix bad nevent/naddr parsing bug. 2024-09-05 14:43:34 -03:00
fiatjaf
8a934cc76b fix fetch naddr missing kind. 2024-08-28 16:13:19 -03:00
fiatjaf
e0c967efa9 fix natural timestamps test. 2024-08-26 15:59:48 -03:00
fiatjaf
36c32ae308 make it possible to have empty content on kind 1.
fixes https://github.com/fiatjaf/nak/issues/32
2024-08-26 15:49:13 -03:00
fiatjaf
6d23509d8c fetch: handle note1 case. 2024-08-25 17:10:06 -03:00
fiatjaf
29b6ecbafe readme: how to download torrents. 2024-08-25 08:35:25 -03:00
fiatjaf
11f37afa5b readme: how to watch livestreams from your terminal. 2024-08-24 21:40:08 -03:00
fiatjaf
cf1694704e bunker: fix printing bunker uri. 2024-08-23 16:17:17 -03:00
fiatjaf
b3ef2c1289 update go-nostr because parallel work generation was broken. 2024-08-21 17:09:15 -03:00
fiatjaf
cfdea699bc fix using NOSTR_SECRET_KEY environment variable. 2024-08-21 10:46:29 -03:00
fiatjaf
014c6bc11d --pow: parallel work. 2024-08-20 23:06:14 -03:00
fiatjaf
0240866fa1 fix fetch for non-pubkey cases. 2024-08-20 18:39:17 -03:00
fiatjaf
a4d9ceecfa do it again because blergh. 2024-08-20 17:13:01 -03:00
fiatjaf
56657d8aa9 update go-nostr. 2024-08-20 15:10:18 -03:00
fiatjaf
ea7b88cfd7 fix fetch with nip05 filter and make req filter options generalize to fetch.
related: https://github.com/fiatjaf/nak/issues/19
2024-08-20 10:59:38 -03:00
fiatjaf
2042b14578 nak fetch: support nip05 codes.
addresses https://github.com/fiatjaf/nak/issues/19
2024-08-20 10:48:09 -03:00
fiatjaf
9d43e66fac nak event --pow
closes https://github.com/fiatjaf/nak/issues/29
2024-08-20 10:34:47 -03:00
fiatjaf
85e9610265 test natural timestamps. 2024-08-20 10:29:00 -03:00
fiatjaf
2edfa5cbea nak serve 2024-08-19 12:49:52 -03:00
fiatjaf
9690dc70cb nak req --paginate 2024-08-18 23:38:03 -03:00
fiatjaf
c90e61dbec set .DisableSliceFlagSeparator to true.
fixes nostr:nevent1qqs9qwgwnr2rzguzrgt99hhhyv8e84mcdr4mnk86uvm6ndjvzl4rjxqpzpmhxue69uhkztnwdaejumr0dshsz9mhwden5te0vf5hgcm0d9hx2u3wwdhkx6tpdshszxnhwden5te0vfhhxarj9ekx2cm5w4exjene9ehx2ap0j8u0fj
2024-08-07 11:46:08 -03:00
fiatjaf
d226cd6ce4 fix password input lowercasing characters.
fixes https://github.com/fiatjaf/nak/issues/28
2024-08-06 11:05:08 -03:00
fiatjaf
3d78e91f62 bunker: deny getPublicKey() even though it's harmless.
fixes https://github.com/fiatjaf/nak/issues/27
2024-08-06 10:56:08 -03:00
fiatjaf
84965f2253 don't set limit to zero on --stream 2024-08-03 10:52:46 -03:00
fiatjaf
928c73513c just move imports around. 2024-07-30 11:43:14 -03:00
fiatjaf
a36142604d compile to riscv64. 2024-07-27 10:32:32 -03:00
fiatjaf
220fe84f1b hardcode our fork of urfave/cli because go is stupid.
fixes https://github.com/fiatjaf/nak/issues/26
2024-07-23 15:23:13 -03:00
fiatjaf
48c0e342e3 only look for private key in environment variable if --sec is not given. 2024-07-23 15:21:14 -03:00
jeremyd
ec2e214c02 Allow setting private key via ENV variable 2024-07-23 15:18:09 -03:00
fiatjaf
9f62d4679f increase stdin line limit. 2024-07-17 13:16:40 -03:00
fiatjaf
809865ca0c relay management: adhere to NIP-98 stupidity. 2024-07-16 13:26:54 -03:00
fiatjaf
813ab3b6ac test flags after args. 2024-07-14 20:34:44 -03:00
fiatjaf
09ed2a040a make tests work again. 2024-07-14 16:18:34 -03:00
fiatjaf
7846960c4e use latest fixed version of urfave/cli fork with reorder flags fixed. 2024-07-14 12:46:13 -03:00
fiatjaf
ce6bb0aa22 natural time examples. 2024-07-13 13:17:20 -03:00
fiatjaf
49ce12ffc2 use natural date parser thing for req "since", "until" and event "ts". 2024-07-13 13:07:49 -03:00
fiatjaf
a5013c513d disallow negative kinds and limits. 2024-07-13 13:07:49 -03:00
fiatjaf
8f51fe757b remove nson.
it's not being used by anyone and didn't gain enough traction, and also
now I think I have a more efficient way of encoding this, so using that
new scheme in the future will be better than this.
2024-07-13 13:06:51 -03:00
fiatjaf
30ca5776c5 global -q is persistent. 2024-07-13 09:42:43 -03:00
fiatjaf
e18e8c00e7 add 9 new examples to readme. 2024-07-12 19:17:22 -03:00
fiatjaf
bca4362ca5 fix inconsistency in nak key decrypt output: print hex always. 2024-07-12 19:15:13 -03:00
fiatjaf
54c4be10bd fix and improve flag reordering for subcommands. 2024-07-12 18:51:07 -03:00
fiatjaf
27f925c05e left pad keys on nak key too so nak key public 02 works, for example. 2024-07-12 14:04:14 -03:00
fiatjaf
79cb63a1b4 add all other relay management rpc methods. 2024-07-12 13:57:58 -03:00
fiatjaf
5ee0036128 implement nip86 client: making management RPC calls to relays. 2024-07-11 15:34:15 -03:00
fiatjaf
316d94166e fix lineProcessingError() -- it wasn't returning a ctx so it was a noop. 2024-07-11 15:33:44 -03:00
fiatjaf
2ca6bb0940 update tests so they can run again (but they're not working). 2024-07-10 14:48:18 -03:00
fiatjaf
ac00c5065f nak req: --force-pre-auth flag. 2024-07-10 14:48:02 -03:00
fiatjaf
441ee9a5ed update go-nostr so just "localhost[:port]" works as a relay url. 2024-07-05 00:11:59 -03:00
fiatjaf
9a41450209 use modified cli library that accepts flags after arguments. 2024-06-25 23:23:51 -03:00
fiatjaf
dba2ed0b5f update to cli v3. 2024-06-25 22:18:26 -03:00
fiatjaf
2079ddf818 support prompting for a password on nak decrypt. 2024-06-25 13:46:15 -03:00
fiatjaf
2135b68106 more aliases in nak encode flags. 2024-06-13 12:05:27 -03:00
fiatjaf
9f98a0aea3 fix nak key encrypt reading from stdin. 2024-06-12 08:54:00 -03:00
fiatjaf
1ba39ca7d7 print bunker restart command without schemes in relay urls when possible. 2024-06-07 06:30:58 -03:00
fiatjaf
262c0c892a fix simple test event ordering. 2024-06-06 15:43:42 -03:00
fiatjaf
363bd66a8a accept relay URLs without scheme everywhere. 2024-06-06 15:38:40 -03:00
fiatjaf
eccce6dc4a nak key combine now returns all possible combinations. 2024-05-16 19:50:52 -03:00
fiatjaf
31f007ffc2 fix: musig2 event to cli args was generating multivalue tags wrongly. 2024-05-15 18:49:22 -03:00
fiatjaf
bb45059218 refactor bunker to work better. remove prompts, use lists of keys and secrets and a new random key. 2024-05-15 17:31:01 -03:00
fiatjaf
71dfe583ed rename flags from --musig2-... to --musig-..., add id to event and other small tweaks. 2024-05-14 23:52:56 -03:00
fiatjaf
84bde7dacd musig2 works now. 2024-05-14 23:41:12 -03:00
fiatjaf
81968f6c0c nak key combine and nak event --musig2 2024-05-14 15:23:08 -03:00
fiatjaf
f198a46c19 remove wss:// from relay urls in readme. 2024-04-29 08:36:59 -03:00
fiatjaf
c3ea9c15f6 LimitZero when -l 0 and when --stream 2024-03-29 08:16:28 -03:00
Yasuhiro Matsumoto
8ddb9ce021 fix color output on Windows 2024-03-28 15:52:30 -03:00
fiatjaf
569d38a137 accept multiple arguments in many commands, add a lot of more tests. 2024-03-19 11:34:59 -03:00
fiatjaf
34c189af28 bunker improvements. 2024-03-02 08:18:40 -03:00
fiatjaf
ffe2db7f96 event: accept tags with a single item. 2024-02-21 09:47:34 -03:00
fiatjaf
c5f7926471 bunker: repeat connection info every now and then. 2024-02-17 17:56:57 -03:00
fiatjaf
e008e08105 bunker: send responses to relays concurrently. 2024-02-16 11:08:48 -03:00
fiatjaf
5dd5a7c699 bunker: better colors and prompts. 2024-02-12 15:39:13 -03:00
fiatjaf
347a82eaa9 verify: accept event to be verified as json argument. 2024-02-12 15:38:43 -03:00
fiatjaf
e89823b10e ensure at least one blank line will be emitted when piped. 2024-02-06 12:47:58 -03:00
fiatjaf
6626001dd2 --connect-as to specify client pubkey when using --connect to bunker 2024-02-06 12:47:46 -03:00
fiatjaf
b7a7e0504f --connect to use nip46 as a client to sign event and auth messages. 2024-02-06 00:58:26 -03:00
mattn
01e1f52a70 fix panic (#12)
* c.Args().Len() - 1 might be negative value

* fix getStdinLinesOrFirstArgument

* fix getStdinLinesOrFirstArgument
2024-02-03 10:03:32 -03:00
fiatjaf
0b9e861f90 fix: print prompt to stderr. 2024-02-02 14:13:11 -03:00
fiatjaf
bda18e035a fix reading hex secret key from input. 2024-02-02 14:11:58 -03:00
fiatjaf
0d46d48881 fix naddr. 2024-01-29 15:37:21 -03:00
fiatjaf
6f24112b5e support ncryptsec in all operations that require a private key and have a nice password prompt. 2024-01-29 10:56:58 -03:00
fiatjaf
f4921f1fe9 nak key: generate, public, encrypt, decrypt. 2024-01-25 08:21:09 -03:00
fiatjaf
3dfcec69b7 --nevent flag on nak event to print an nevent at the end. 2024-01-24 22:43:23 -03:00
fiatjaf
14b69f36cf -q to silence stderr, -qq to silence everything. 2024-01-24 22:38:51 -03:00
fiatjaf
3f7089e27e signal that we accept patches over NIP-34. 2024-01-23 10:20:54 -03:00
fiatjaf
6a75c8aec3 UseShortOptionHandling and Suggest 2024-01-23 10:20:54 -03:00
fiatjaf
b17887fe21 replace validate32BytesHex() with native calls from go-nostr. 2024-01-21 07:45:22 -03:00
fiatjaf
77103cae0c req: -d flag too. 2024-01-17 08:50:59 -03:00
fiatjaf
59a2c16b42 event: -d shortcut flag and use .AppendUnique() 2024-01-17 08:49:18 -03:00
fiatjaf
48d19196bb fix publishing to multiple relays. 2024-01-16 09:16:56 -03:00
fiatjaf
ad7010e506 use scanner.Buffer() to make stdin able to parse hellish giant events up to 256kb (the default was 64kb). 2024-01-11 21:48:54 -03:00
fiatjaf
584881266e bunker: update to go-nostr nip46 api breaking change. 2024-01-11 21:41:50 -03:00
fiatjaf
a30f422d7d close relay websockets cleanly. 2024-01-11 21:29:46 -03:00
fiatjaf
637b9440ef upgrade go-nostr and xsync. 2024-01-10 21:19:19 -03:00
fiatjaf
16c1e795bd fetch: more places to fetch relay lists from. 2024-01-02 11:05:43 -03:00
OHASHI Hideya
8373da647e Fix tags with values containing = 2023-12-24 09:29:29 -03:00
fiatjaf
f295f130f2 remove .scalafmt.conf 2023-12-23 21:30:58 -03:00
fiatjaf
5415fd369c update go-nostr to fix pool infinite loop. 2023-12-15 11:15:12 -03:00
Daniel Cadenas
f35cb4bd1d Fix tags with values containing = 2023-12-13 20:48:01 -03:00
Yasuhiro Matsumoto
242b028656 -until now 2023-12-12 13:34:30 -03:00
Yasuhiro Matsumoto
f0d90b567c -since now 2023-12-12 13:34:30 -03:00
fiatjaf
2d1e27f766 fix for nil error case on publish. 2023-12-10 20:49:08 -03:00
fiatjaf
bfa72640cd bunker: a better prompt. 2023-12-09 17:42:01 -03:00
fiatjaf
e5b0b15908 bunker: improve error message. 2023-12-09 17:11:55 -03:00
fiatjaf
0860cfcf6d rename nsecbunker->bunker. 2023-12-09 16:37:59 -03:00
fiatjaf
b7b61c0723 support --auth/--sec/--prompt-sec on req. 2023-12-09 16:32:04 -03:00
fiatjaf
ed3156ae10 fix event publishing flow: no need to reconnect and AUTH messages make sense. 2023-12-09 09:14:45 -03:00
fiatjaf
30dbe2c1c0 fix handling multiple lines in event (broken in previous commit). 2023-12-07 18:14:26 -03:00
fiatjaf
4d75605c20 print event more consistently and auth when required and allowed. 2023-12-07 18:12:18 -03:00
fiatjaf
26b1aa359a nsecbunker/nip46 is working now. 2023-12-02 15:33:37 -03:00
fiatjaf
bc7cd0939c nsecbunker work-in-progress. 2023-12-02 12:20:15 -03:00
fiatjaf
5657fdc6a7 update go-nostr. 2023-12-01 13:22:04 -03:00
fiatjaf
d9d36e7619 use easyjson and envelopes. 2023-11-28 15:18:43 -03:00
fiatjaf
f2f9dda33a actually this is the fix. 2023-11-24 21:19:10 -03:00
fiatjaf
53cb2c0490 event: fix handling of -k and kind in stdin event, and default to 1. 2023-11-24 21:08:13 -03:00
fiatjaf
4a3c7dc825 remove extra whitespace on usage strings. 2023-11-20 15:01:53 -03:00
fiatjaf
05f2275c9e nak relay 2023-11-20 15:00:50 -03:00
fiatjaf
082be94614 update go-nostr. 2023-11-19 07:20:52 -03:00
fiatjaf
15217f2466 fetch: fix handling of --relay tags. 2023-11-15 09:48:57 -03:00
fiatjaf
8fbfdc65c8 add --silent global option to remove the stderr logs. 2023-11-13 15:03:27 -03:00
fiatjaf
11fe6b5809 connect to relays once per call instead of in each iteration and fail early if no connection works. 2023-11-13 14:57:35 -03:00
30 changed files with 4075 additions and 601 deletions

View File

@@ -25,10 +25,20 @@ jobs:
strategy:
matrix:
goos: [linux, freebsd, darwin, windows]
goarch: [amd64, arm64]
goarch: [arm, amd64, arm64, riscv64]
exclude:
- goarch: arm64
goos: windows
- goarch: riscv64
goos: windows
- goarch: riscv64
goos: darwin
- goarch: arm
goos: windows
- goarch: arm
goos: darwin
- goarch: arm
goos: freebsd
steps:
- uses: actions/checkout@v3
- uses: wangyoucao577/go-release-action@v1.40
@@ -36,6 +46,7 @@ jobs:
github_token: ${{ secrets.GITHUB_TOKEN }}
goos: ${{ matrix.goos }}
goarch: ${{ matrix.goarch }}
ldflags: -X main.version=${{ github.ref_name }}
overwrite: true
md5sum: false
sha256sum: false

10
.gitignore vendored
View File

@@ -1,9 +1 @@
target
.bsp
globals.bundle.js
yarn.lock
node_modules
project/project
.metals
.bloop
project/metals.sbt
nak

View File

@@ -1,2 +0,0 @@
version = 3.5.8
runner.dialect = scala3

165
README.md
View File

@@ -16,7 +16,7 @@ take a look at the help text that comes in it to learn all possibilities, but he
### make a nostr event with custom content and tags, sign it with a different key and publish it to two relays
```shell
~> nak event --sec 02 -c 'good morning' --tag t=gm wss://nostr-pub.wellorder.net wss://relay.damus.io
~> nak event --sec 02 -c 'good morning' --tag t=gm nostr-pub.wellorder.net relay.damus.io
{"id":"e20978737ab7cd36eca300a65f11738176123f2e0c23054544b18fe493e2aa1a","pubkey":"c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5","created_at":1698632753,"kind":1,"tags":[["t","gm"]],"content":"good morning","sig":"5687c1a97066c349cb3bde0c0719fd1652a13403ba6aca7557b646307ee6718528cd86989db08bf6a7fd04bea0b0b87c1dd1b78c2d21b80b80eebab7f40b8916"}
publishing to wss://nostr-pub.wellorder.net... success.
publishing to wss://relay.damus.io... success.
@@ -24,7 +24,7 @@ publishing to wss://relay.damus.io... success.
### query a bunch of relays for a tag with a limit of 2 for each, print their content
```shell
~> nak req -k 1 -t t=gm -l 2 wss://nostr.mom wss://nostr.wine wss://nostr-pub.wellorder.net | jq .content
~> nak req -k 1 -t t=gm -l 2 nostr.mom nostr.wine nostr-pub.wellorder.net | jq .content
"#GM, you sovereign savage #freeple of the #nostrverse. Let's cause some #nostroversy. "
"ITM slaves!\n#gm https://image.nostr.build/cbbcdf80bfc302a6678ecf9387c87d87deca3e0e288a12e262926c34feb3f6aa.jpg "
"good morning"
@@ -34,13 +34,13 @@ publishing to wss://relay.damus.io... success.
### decode a nip19 note1 code, add a relay hint, encode it back to nevent1
```shell
~> nak decode note1ttnnrw78wy0hs5fa59yj03yvcu2r4y0xetg9vh7uf4em39n604vsyp37f2 | jq -r .id | nak encode nevent -r wss://nostr.zbd.gg
~> nak decode note1ttnnrw78wy0hs5fa59yj03yvcu2r4y0xetg9vh7uf4em39n604vsyp37f2 | jq -r .id | nak encode nevent -r nostr.zbd.gg
nevent1qqs94ee3h0rhz8mc2y76zjf8cjxvw9p6j8nv45zktlwy6uacjea86kgpzfmhxue69uhkummnw3ezu7nzvshxwec8zw8h7
~> nak decode nevent1qqs94ee3h0rhz8mc2y76zjf8cjxvw9p6j8nv45zktlwy6uacjea86kgpzfmhxue69uhkummnw3ezu7nzvshxwec8zw8h7
{
"id": "5ae731bbc7711f78513da14927c48cc7143a91e6cad0565fdc4d73b8967a7d59",
"relays": [
"wss://nostr.zbd.gg"
"nostr.zbd.gg"
]
}
```
@@ -61,7 +61,7 @@ nak fetch nevent1qqs2e3k48vtrkzjm8vvyzcmsmkf58unrxtq2k4h5yspay6vhcqm4wqcpz9mhxue
### republish an event from one relay to multiple others
```shell
~> nak req -i e20978737ab7cd36eca300a65f11738176123f2e0c23054544b18fe493e2aa1a wss://nostr.wine/ wss://nostr-pub.wellorder.net | nak event wss://nostr.wine wss://offchain.pub wss://public.relaying.io wss://eden.nostr.land wss://atlas.nostr.land wss://relayable.org
~> nak req -i e20978737ab7cd36eca300a65f11738176123f2e0c23054544b18fe493e2aa1a nostr.wine/ nostr-pub.wellorder.net | nak event nostr.wine offchain.pub public.relaying.io eden.nostr.land atlas.nostr.land relayable.org
{"id":"e20978737ab7cd36eca300a65f11738176123f2e0c23054544b18fe493e2aa1a","pubkey":"c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5","created_at":1698632753,"kind":1,"tags":[["t","gm"]],"content":"good morning","sig":"5687c1a97066c349cb3bde0c0719fd1652a13403ba6aca7557b646307ee6718528cd86989db08bf6a7fd04bea0b0b87c1dd1b78c2d21b80b80eebab7f40b8916"}
publishing to wss://nostr.wine... failed: msg: blocked: not an active paid member
publishing to wss://offchain.pub... success.
@@ -77,7 +77,158 @@ publishing to wss://relayable.org... success.
invalid .id, expected 05bd99d54cb835f427e0092c4275ee44c7ff51219eff417c19f70c9e2c53ad5a, got 05bd99d54cb835f327e0092c4275ee44c7ff51219eff417c19f70c9e2c53ad5a
```
### fetch all quoted events by a given pubkey in their last 100 notes
### fetch all quoted events by a given pubkey in their last 10 notes of 2023
```shell
nak req -l 100 -k 1 -a 2edbcea694d164629854a52583458fd6d965b161e3c48b57d3aff01940558884 wss://relay.damus.io | jq -r '.content | match("nostr:((note1|nevent1)[a-z0-9]+)";"g") | .captures[0].string' | nak decode | jq -cr '{ids: [.id]}' | nak req wss://relay.damus.io
~> nak req -l 10 -k 1 --until 'December 31 2023' -a 2edbcea694d164629854a52583458fd6d965b161e3c48b57d3aff01940558884 relay.damus.io | jq -r '.content | match("nostr:((note1|nevent1)[a-z0-9]+)";"g") | .captures[0].string' | nak decode | jq -cr '{ids: [.id]}' | nak req relay.damus.io
connecting to relay.damus.io...
ok.
{"kind":1,"id":"0000000a5109c9747e3847282fcaef3d221d1be5e864ced7b2099d416a18d15a","pubkey":"7bdef7be22dd8e59f4600e044aa53a1cf975a9dc7d27df5833bc77db784a5805","created_at":1703869609,"tags":[["nonce","12912720851599460299","25"]],"content":"https://image.nostr.build/5eb40d3cae799bc572763b8f8bee95643344fa392d280efcb0fd28a935879e2a.png\n\nNostr is not dying.\nIt is just a physiological and healthy slowdown on the part of all those who have made this possible in such a short time, sharing extraordinary enthusiasm. This is necessary to regain a little energy, it will allow some things to be cleaned up and more focused goals to be set.\n\nIt is like the caterpillar that is about to become a butterfly, it has to stop moving, acting, doing all the time; it has to do one last silent work and rest, letting time go by. And then a new phase of life can begin.\n\nWe have an amazing 2024 ahead.\nThank you all, who have given so much and believe in Nostr.\n\nPS: an interesting cue suggested by this image, you cannot have both silk and butterfly, you have to choose: a precious and sophisticated ornament, or the living, colorful beauty of freedom.","sig":"16fe157fb13dba2474d510db5253edc409b465515371015a91b26b8f39e5aa873453bc366947c37463c49466f5fceb7dea0485432f979a03471c8f76b73e553c"}
{"kind":1,"id":"ac0cc72dfee39f41d94568f574e7b613d3979facbd7b477a16b52eb763db4b6e","pubkey":"2250f69694c2a43929e77e5de0f6a61ae5e37a1ee6d6a3baef1706ed9901248b","created_at":1703873865,"tags":[["r","https://zine.wavlake.com/2023-year-in-review/"]],"content":"It's been an incredible year for us here at Wavlake and we wanted to take a moment to look back and see how far we've come since launch. Read more.. https://zine.wavlake.com/2023-year-in-review/","sig":"189e354f67f48f3046fd762c83f9bf3a776d502d514e2839a1b459c30107a02453304ef695cdc7d254724041feec3800806b21eb76259df87144aaef821ace5b"}
{"kind":1,"id":"6215766c5aadfaf51488134682f7d28f237218b5405da2fc11d1fefe1ebf8154","pubkey":"4ce6abbd68dab6e9fdf6e8e9912a8e12f9b539e078c634c55a9bff2994a514dd","created_at":1703879775,"tags":[["imeta","url https://video.nostr.build/7b4e7c326fa4fcba58a40914ce9db4f060bd917878f2194f6d139948b085ebb9.mp4","blurhash eHD,QG_4ogMu_3to%O-:MwM_IWRjx^-pIUoe-;t7%Nt7%gV?M{WBxu","dim 480x268"],["t","zaps"],["t","powakili23"],["p","4f82bced42584a6acfced2a657b5acabc4f90d75a95ed3ff888f3b04b8928630"],["p","ce75bae2349804caa5f4de8ae8f775bb558135f412441d9e32f88e4226c5d165"],["p","94bd495b78f8f6e5aff8ebc90e052d3a409d1f9d82e43ab56ca2cafb81b18ddf"],["p","50ff5b7ebeac1cc0d03dc878be8a59f1b63d45a7d5e60ade4b6f6f31eca25954"],["p","f300cf2bdf9808ed229dfa468260753a0b179935bdb87612b6d4f5b9fe3fc7cf"],["r","https://geyser.fund/entry/2636"],["r","https://video.nostr.build/7b4e7c326fa4fcba58a40914ce9db4f060bd917878f2194f6d139948b085ebb9.mp4"]],"content":"POWA - HQ UPDATE - DEC 2023\nTLDR: plan to open January 2024, 1 million Sats to go to reach milestone. #zaps go to fund this project. ⚡powa@geyser.fund\n\nHello,\n\nFirst and foremost, Id like to thank you for the incredible support shown for this project. Its been an absolute honor to oversee this Proof of Work initiative.\n\nI am thrilled to announce that we are right on track for the grand opening in January 2024.\n\nCurrently, we're just over 1 million Sats away from reaching our target for this phase.\n\nPlease take a moment to enjoy the video and stay tuned for further updates about POWA. \n\nMan Like Who?\nMan Like Kweks!\n🇹🇿⚡💜🏔\n#powakili23\nnostr:npub1f7ptem2ztp9x4n7w62n90ddv40z0jrt4490d8lug3uasfwyjsccqkknerm nostr:npub1ee6m4c35nqzv4f05m69w3am4hd2czd05zfzpm83jlz8yyfk969js78tfcv nostr:npub1jj75jkmclrmwttlca0ysupfd8fqf68uastjr4dtv5t90hqd33h0s4gcksp nostr:npub12rl4kl474swvp5paeputazje7xmr63d86hnq4hjtdahnrm9zt92qgq500s nostr:npub17vqv727lnqyw6g5alfrgycr48g930xf4hku8vy4k6n6mnl3lcl8sglecc5 \n\nhttps://geyser.fund/entry/2636 https://video.nostr.build/7b4e7c326fa4fcba58a40914ce9db4f060bd917878f2194f6d139948b085ebb9.mp4 ","sig":"97d13c17d91c319f343cc770222d6d4a0a714d0e7e4ef43373adaf215a4c077f0bdf12bac488c74dbd4d55718d46c17a617b93c8660736b70bcd61a8820ece67"}
...
```
### sign an event collaboratively with multiple parties using musig2
```shell
~> nak event --sec 1234 -k 1 -c 'hello from a combined key' --musig 2
the following code should be saved secretly until the next step an included with --musig-nonce-secret:
QebOT03ERmV7km22CqEqBPFmzAkgxQzGGbR7Si8yIZCBrd1N9A3LKwGLO71kbgXZ9EYFKpjiwun4u0mj5Tq6vwM3pK7x+EI8oHbkt9majKv/QN24Ix8qnwEIHxXX+mXBug==
the next signer and they should call this on their side:
nak event --sec <insert-secret-key> --musig 2 -k 1 -ts 1720821287 -c 'hello from a combined key' --musig-pubkey 0337a4aef1f8423ca076e4b7d99a8cabff40ddb8231f2a9f01081f15d7fa65c1ba --musig-nonce 0285af37c6c43638cda2c773098e867c749ddf1e9d096b78686c5d000603935ad3025c4a1e042eb6b0dcfd864d1e072d2ce8da06f2c0dcf13fd7d1fcef0dd26dbc92
```
demo videos with [2](https://njump.me/nevent1qqs8pmmae89agph80928l6gjm0wymechqazv80jwqrqy4cgk08epjaczyqalp33lewf5vdq847t6te0wvnags0gs0mu72kz8938tn24wlfze674zkzz), [3](https://njump.me/nevent1qqsrp320drqcnmnam6jvmdd4lgdvh2ay0xrdesrvy6q9qqdfsk7r55qzyqalp33lewf5vdq847t6te0wvnags0gs0mu72kz8938tn24wlfze6c32d4m) and [4](https://njump.me/nevent1qqsre84xe6qpagf2w2xjtjwc95j4dd5ccue68gxl8grkd6t6hjhaj5qzyqalp33lewf5vdq847t6te0wvnags0gs0mu72kz8938tn24wlfze6t8t7ak) parties.
### generate a private key
```shell
~> nak key generate 18:59
7b94e287b1fafa694ded1619b27de7effd3646104a158e187ff4edc56bc6148d
```
### encrypt key with NIP-49
```shell
~> nak key encrypt 7b94e287b1fafa694ded1619b27de7effd3646104a158e187ff4edc56bc6148d mypassword
ncryptsec1qggx54cg270zy9y8krwmfz29jyypsuxken2fkk99gr52qhje968n6mwkrfstqaqhq9eq94pnzl4nff437l4lp4ur2cs4f9um8738s35l2esx2tas48thtfhrk5kq94pf9j2tpk54yuermra0xu6hl5ls
```
### decrypt key with NIP-49
```shell
~> nak key decrypt ncryptsec1qggx54cg270zy9y8krwmfz29jyypsuxken2fkk99gr52qhje968n6mwkrfstqaqhq9eq94pnzl4nff437l4lp4ur2cs4f9um8738s35l2esx2tas48thtfhrk5kq94pf9j2tpk54yuermra0xu6hl5ls mypassword
7b94e287b1fafa694ded1619b27de7effd3646104a158e187ff4edc56bc6148d
~>
~> nak key decrypt ncryptsec1qggx54cg270zy9y8krwmfz29jyypsuxken2fkk99gr52qhje968n6mwkrfstqaqhq9eq94pnzl4nff437l4lp4ur2cs4f9um8738s35l2esx2tas48thtfhrk5kq94pf9j2tpk54yuermra0xu6hl5ls
type the password to decrypt your secret key: **********
7b94e287b1fafa694ded1619b27de7effd3646104a158e187ff4edc56bc6148d
```
### get a public key from a private key
```shell
~> nak key public 7b94e287b1fafa694ded1619b27de7effd3646104a158e187ff4edc56bc6148d
985d66d2644dfa7676e26046914470d66ebc7fa783a3f57f139fde32d0d631d7
```
### sign an event using a remote NIP-46 bunker
```shell
~> nak event --connect 'bunker://a9e0f110f636f3191644110c19a33448daf09d7cda9708a769e91b7e91340208?relay=wss%3A%2F%2Frelay.damus.io&relay=wss%3A%2F%2Frelay.nsecbunker.com&relay=wss%3A%2F%2Fnos.lol&secret=TWfGbjQCLxUf' -c 'hello from bunker'
```
### sign an event using a NIP-49 encrypted key
```shell
~> nak event --sec ncryptsec1qggx54cg270zy9y8krwmfz29jyypsuxken2fkk99gr52qhje968n6mwkrfstqaqhq9eq94pnzl4nff437l4lp4ur2cs4f9um8738s35l2esx2tas48thtfhrk5kq94pf9j2tpk54yuermra0xu6hl5ls -c 'hello from encrypted key'
type the password to decrypt your secret key: **********
{"kind":1,"id":"8aa5c931fb1da507f14801de6a1814b7f0baae984dc502b9889f347f5aa3cc4e","pubkey":"985d66d2644dfa7676e26046914470d66ebc7fa783a3f57f139fde32d0d631d7","created_at":1720822280,"tags":[],"content":"hello from encrypted key","sig":"9d1c9e56e87f787cc5b6191ec47690ce59fa4bef105b56297484253953e18fb930f6683f007e84a9ce9dc9a25b20c191c510629156dcd24bd16e15d302d20944"}
```
### talk to a relay's NIP-86 management API
```shell
nak relay allowpubkey --sec ncryptsec1qggx54cg270zy9y8krwmfz29jyypsuxken2fkk99gr52qhje968n6mwkrfstqaqhq9eq94pnzl4nff437l4lp4ur2cs4f9um8738s35l2esx2tas48thtfhrk5kq94pf9j2tpk54yuermra0xu6hl5ls --pubkey a9e0f110f636f3191644110c19a33448daf09d7cda9708a769e91b7e91340208 pyramid.fiatjaf.com
type the password to decrypt your secret key: **********
calling 'allowpubkey' on https://pyramid.fiatjaf.com...
{
"result": null,
"error": "failed to add to whitelist: pubkey 985d66d2644dfa7676e26046914470d66ebc7fa783a3f57f139fde32d0d631d7 doesn't have permission to invite"
}
```
### start a bunker locally
```shell
~> nak bunker --sec ncryptsec1qggrp80ptf0s7kyl0r38ktzg60fem85m89uz7um6rjn4pnep2nnvcgqm8h7q36c76z9sypatdh4fmw6etfxu99mv5cxkw4ymcsryw0zz7evyuplsgvnj5yysf449lq94klzvnahsw2lzxflvcq4qpf5q -k 3fbf7fbb2a2111e205f74aca0166e29e421729c9a07bc45aa85d39535b47c9ed relay.damus.io nos.lol relay.nsecbunker.com
connecting to relay.damus.io... ok.
connecting to nos.lol... ok.
connecting to relay.nsecbunker.com... ok.
type the password to decrypt your secret key: ***
listening at [wss://relay.damus.io wss://nos.lol wss://relay.nsecbunker.com]:
pubkey: f59911b561c37c90b01e9e5c2557307380835c83399756f4d62d8167227e420a
npub: npub17kv3rdtpcd7fpvq7newz24eswwqgxhyr8xt4daxk9kqkwgn7gg9q4gy8vf
authorized keys:
- 3fbf7fbb2a2111e205f74aca0166e29e421729c9a07bc45aa85d39535b47c9ed
to restart: nak bunker --sec ncryptsec1qggrp80ptf0s7kyl0r38ktzg60fem85m89uz7um6rjn4pnep2nnvcgqm8h7q36c76z9sypatdh4fmw6etfxu99mv5cxkw4ymcsryw0zz7evyuplsgvnj5yysf449lq94klzvnahsw2lzxflvcq4qpf5q -k 3fbf7fbb2a2111e205f74aca0166e29e421729c9a07bc45aa85d39535b47c9ed relay.damus.io nos.lol relay.nsecbunker.com
bunker: bunker://f59911b561c37c90b01e9e5c2557307380835c83399756f4d62d8167227e420a?relay=wss%3A%2F%2Frelay.damus.io&relay=wss%3A%2F%2Fnos.lol&relay=wss%3A%2F%2Frelay.nsecbunker.com&secret=XuuiMbcLwuwL
```
### generate a NIP-70 protected event with a date set to two weeks ago and some multi-value tags
```shell
~> nak event --ts 'two weeks ago' -t '-' -t 'e=f59911b561c37c90b01e9e5c2557307380835c83399756f4d62d8167227e420a;wss://relay.whatever.com;root;a9e0f110f636f3191644110c19a33448daf09d7cda9708a769e91b7e91340208' -t 'p=a9e0f110f636f3191644110c19a33448daf09d7cda9708a769e91b7e91340208;wss://p-relay.com' -c 'I know the future'
{"kind":1,"id":"f030fccd90c783858dfcee204af94826cf0f1c85d6fc85a0087e9e5172419393","pubkey":"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798","created_at":1719677535,"tags":[["-"],["e","f59911b561c37c90b01e9e5c2557307380835c83399756f4d62d8167227e420a","wss://relay.whatever.com","root","a9e0f110f636f3191644110c19a33448daf09d7cda9708a769e91b7e91340208"],["p","a9e0f110f636f3191644110c19a33448daf09d7cda9708a769e91b7e91340208","wss://p-relay.com"]],"content":"I know the future","sig":"8b36a74e29df8bc12bed66896820da6940d4d9409721b3ed2e910c838833a178cb45fd5bb1c6eb6adc66ab2808bfac9f6644a2c55a6570bb2ad90f221c9c7551"}
```
### download the latest 50000 notes from a relay, regardless of their natural query limits, by paginating requests
```shell
~> nak req -k 1 --limit 50000 --paginate --paginate-interval 2s nos.lol > events.jsonl
~> wc -l events.jsonl
50000 events.jsonl
```
### run a somewhat verbose local relay for test purposes
```shell
~> nak serve
> relay running at ws://localhost:10547
got request {"kinds":[1],"authors":["79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"],"since":1724082362}
got event {"kind":1,"id":"e3c6bf630d6deea74c0ee2f7f7ba6da55a627498a32f1e72029229bb1810bce3","pubkey":"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798","created_at":1724082366,"tags":[],"content":"two","sig":"34261cf226c3fee2df24e55a89f43f5349c98a64bce46bdc46807b0329f334cea93e9e8bc285c1259a5684cf23f5e507c8e6dad47a31a6615d706b1130d09e69"}
got event {"kind":1,"id":"0bbb397c8f87ae557650b9d6ee847292df8e530c458ffea1b24bdcb7bed0ec5e","pubkey":"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798","created_at":1724082369,"tags":[],"content":"three","sig":"aa1cb7d5f0f03f358fc4c0a4351a4f1c66e3a7627021b618601c56ba598b825b6d95d9c8720a4c60666a7eb21e17018cf326222f9f574a9396f2f2da7f007546"}
• events stored: 2, subscriptions opened: 1
got event {"kind":1,"id":"029ebff759dd54dbd01b929f879fea5802de297e1c3768ca16d9b97cc8bca38f","pubkey":"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798","created_at":1724082371,"tags":[],"content":"four","sig":"9816de517d87d4c3ede57c1c50e3c237486794241afadcd891e1acbba2c5e672286090e6ad3402b047d69bae8095bc4e20e57ac70d92386dfa26db216379330f"}
got event {"kind":1,"id":"fe6489fa6fbb925be839377b9b7049d73be755dc2bdad97ff6dd9eecbf8b3a32","pubkey":"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798","created_at":1724082383,"tags":[],"content":"five","sig":"865ce5e32eead5bdb950ac1fbc55bc92dde26818ee3136634538ec42914de179a51e672c2d4269d4362176e5e8cd5e08e69b35b91c6c2af867e129b93d607635"}
got request {"kinds":[30818]}
• events stored: 4, subscriptions opened: 1
```
### make an event with a PoW target
```shell
~> nak event -c 'hello getwired.app and labour.fiatjaf.com' --pow 24
{"kind":1,"id":"0000009dcc7c62056eafdb41fac817379ec2becf0ce27c5fbe98d0735d968147","pubkey":"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798","created_at":1724160828,"tags":[["nonce","515504","24"]],"content":"hello getwired.app and labour.fiatjaf.com","sig":"7edb988065ccc12779fe99270945b212f3723838f315d76d5e90e9ffa27198f13fa556614295f518d968d55bab81878167d4162b3a7cf81a6b423c6761bd504c"}
```
### make a nostr event signed with a key given as an environment variable
```shell
~> export NOSTR_SECRET_KEY=ncryptsec1qggyy9vw0nclmw8ly9caz6aa7f85a4ufhsct64uva337pulsdw00n6twa2lzhzk2znzsyu60urx9s08lx00ke6ual3lszyn5an9zarm6s70lw5lj6dv3mj3f9p4tvp0we6qyz4gp420mapfmvqheuttv
~> nak event -c 'it supports keys as hex, nsec or ncryptsec'
type the password to decrypt your secret key: ********
{"kind":1,"id":"5cbf3feb9a7d99c3ee2a88693a591caca1a8348fea427b3652c27f7a8a76af48","pubkey":"b00bcab55375d8c7b731dd9841f6d805ff1cf6fdc945e7326786deb5ddac6ce4","created_at":1724247924,"tags":[],"content":"it supports keys as hex, nsec or ncryptsec","sig":"fb3fd170bc10e5042322c7a05dd4bbd8ac9947b39026b8a7afd1ee02524e8e3aa1d9554e9c7b6181ca1b45cab01cd06643bdffa5ce678b475e6b185e1c14b085"}
```
### download some helpful `jq` functions for dealing with nostr events
```shell
~> nak req -i 412f2d3e73acc312942c055ac2a695dc60bf58ff97e06689a8a79e97796c4cdb relay.westernbtc.com | jq -r .content > ~/.jq
```
### watch a NIP-53 livestream (zap.stream etc)
```shell
~> # this requires the jq utils from the step above
~> mpv $(nak fetch naddr1qqjxvvm9xscnsdtx95cxvcfk956rsvtx943rje3k95mx2dp389jnwwrp8ymxgqg4waehxw309aex2mrp0yhxgctdw4eju6t09upzpn6956apxcad0mfp8grcuugdysg44eepex68h50t73zcathmfs49qvzqqqrkvu7ed38k | jq -r 'tag_value("streaming")')
~>
~> # or without the utils
~> mpv $(nak fetch naddr1qqjxvvm9xscnsdtx95cxvcfk956rsvtx943rje3k95mx2dp389jnwwrp8ymxgqg4waehxw309aex2mrp0yhxgctdw4eju6t09upzpn6956apxcad0mfp8grcuugdysg44eepex68h50t73zcathmfs49qvzqqqrkvu7ed38k | jq -r '.tags | map(select(.[0] == "streaming") | .[1])[0]')
```
### download a NIP-35 torrent from an `nevent`
```shell
~> # this requires the jq utils from two steps above
~> aria2c $(nak fetch nevent1qqsdsg6x7uujekac4ga7k7qa9q9sx8gqj7xzjf5w9us0dm0ghvf4ugspp4mhxue69uhkummn9ekx7mq6dw9y4 | jq -r '"magnet:?xt=urn:btih:\(tag_value("x"))&dn=\(tag_value("title"))&tr=http%3A%2F%2Ftracker.loadpeers.org%3A8080%2FxvRKfvAlnfuf5EfxTT5T0KIVPtbqAHnX%2Fannounce&tr=udp%3A%2F%2Ftracker.coppersurfer.tk%3A6969%2Fannounce&tr=udp%3A%2F%2Ftracker.openbittorrent.com%3A6969%2Fannounce&tr=udp%3A%2F%2Fopen.stealth.si%3A80%2Fannounce&tr=udp%3A%2F%2Ftracker.torrent.eu.org%3A451%2Fannounce&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337&tr=\(tags("tracker") | map(.[1] | @uri) | join("&tr="))"')
```
## contributing to this repository
Use NIP-34 to send your patches to `naddr1qqpkucttqy28wumn8ghj7un9d3shjtnwdaehgu3wvfnsz9nhwden5te0wfjkccte9ehx7um5wghxyctwvsq3gamnwvaz7tmjv4kxz7fwv3sk6atn9e5k7q3q80cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsxpqqqpmej2wctpn`.

230
bunker.go Normal file
View File

@@ -0,0 +1,230 @@
package main
import (
"context"
"fmt"
"net/url"
"os"
"slices"
"strings"
"sync"
"time"
"github.com/fatih/color"
"github.com/fiatjaf/cli/v3"
"github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/nip19"
"github.com/nbd-wtf/go-nostr/nip46"
)
var bunker = &cli.Command{
Name: "bunker",
Usage: "starts a nip46 signer daemon with the given --sec key",
ArgsUsage: "[relay...]",
Description: ``,
DisableSliceFlagSeparator: true,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "sec",
Usage: "secret key to sign the event, as hex or nsec",
DefaultText: "the key '1'",
},
&cli.BoolFlag{
Name: "prompt-sec",
Usage: "prompt the user to paste a hex or nsec with which to sign the event",
},
&cli.StringSliceFlag{
Name: "authorized-secrets",
Aliases: []string{"s"},
Usage: "secrets for which we will always respond",
},
&cli.StringSliceFlag{
Name: "authorized-keys",
Aliases: []string{"k"},
Usage: "pubkeys for which we will always respond",
},
},
Action: func(ctx context.Context, c *cli.Command) error {
// try to connect to the relays here
qs := url.Values{}
relayURLs := make([]string, 0, c.Args().Len())
if relayUrls := c.Args().Slice(); len(relayUrls) > 0 {
relays := connectToAllRelays(ctx, relayUrls, false)
if len(relays) == 0 {
log("failed to connect to any of the given relays.\n")
os.Exit(3)
}
for _, relay := range relays {
relayURLs = append(relayURLs, relay.URL)
qs.Add("relay", relay.URL)
}
}
if len(relayURLs) == 0 {
return fmt.Errorf("not connected to any relays: please specify at least one")
}
// gather the secret key
sec, _, err := gatherSecretKeyOrBunkerFromArguments(ctx, c)
if err != nil {
return err
}
// other arguments
authorizedKeys := c.StringSlice("authorized-keys")
authorizedSecrets := c.StringSlice("authorized-secrets")
// this will be used to auto-authorize the next person who connects who isn't pre-authorized
// it will be stored
newSecret := randString(12)
// static information
pubkey, err := nostr.GetPublicKey(sec)
if err != nil {
return err
}
npub, _ := nip19.EncodePublicKey(pubkey)
// this function will be called every now and then
printBunkerInfo := func() {
qs.Set("secret", newSecret)
bunkerURI := fmt.Sprintf("bunker://%s?%s", pubkey, qs.Encode())
authorizedKeysStr := ""
if len(authorizedKeys) != 0 {
authorizedKeysStr = "\n authorized keys:\n - " + colors.italic(strings.Join(authorizedKeys, "\n - "))
}
authorizedSecretsStr := ""
if len(authorizedSecrets) != 0 {
authorizedSecretsStr = "\n authorized secrets:\n - " + colors.italic(strings.Join(authorizedSecrets, "\n - "))
}
preauthorizedFlags := ""
for _, k := range authorizedKeys {
preauthorizedFlags += " -k " + k
}
for _, s := range authorizedSecrets {
preauthorizedFlags += " -s " + s
}
secretKeyFlag := ""
if sec := c.String("sec"); sec != "" {
secretKeyFlag = "--sec " + sec
}
relayURLsPossiblyWithoutSchema := make([]string, len(relayURLs))
for i, url := range relayURLs {
if strings.HasPrefix(url, "wss://") {
relayURLsPossiblyWithoutSchema[i] = url[6:]
} else {
relayURLsPossiblyWithoutSchema[i] = url
}
}
restartCommand := fmt.Sprintf("nak bunker %s%s %s",
secretKeyFlag,
preauthorizedFlags,
strings.Join(relayURLsPossiblyWithoutSchema, " "),
)
log("listening at %v:\n pubkey: %s \n npub: %s%s%s\n to restart: %s\n bunker: %s\n\n",
colors.bold(relayURLs),
colors.bold(pubkey),
colors.bold(npub),
authorizedKeysStr,
authorizedSecretsStr,
color.CyanString(restartCommand),
colors.bold(bunkerURI),
)
}
printBunkerInfo()
// subscribe to relays
now := nostr.Now()
events := sys.Pool.SubMany(ctx, relayURLs, nostr.Filters{
{
Kinds: []int{nostr.KindNostrConnect},
Tags: nostr.TagMap{"p": []string{pubkey}},
Since: &now,
LimitZero: true,
},
})
signer := nip46.NewStaticKeySigner(sec)
handlerWg := sync.WaitGroup{}
printLock := sync.Mutex{}
// just a gimmick
var cancelPreviousBunkerInfoPrint context.CancelFunc
_, cancel := context.WithCancel(ctx)
cancelPreviousBunkerInfoPrint = cancel
// asking user for authorization
signer.AuthorizeRequest = func(harmless bool, from string, secret string) bool {
if secret == newSecret {
// store this key
authorizedKeys = append(authorizedKeys, from)
// discard this and generate a new secret
newSecret = randString(12)
// print bunker info again after this
go func() {
time.Sleep(3 * time.Second)
printBunkerInfo()
}()
}
return slices.Contains(authorizedKeys, from) || slices.Contains(authorizedSecrets, secret)
}
for ie := range events {
cancelPreviousBunkerInfoPrint() // this prevents us from printing a million bunker info blocks
// handle the NIP-46 request event
req, resp, eventResponse, err := signer.HandleRequest(ctx, ie.Event)
if err != nil {
log("< failed to handle request from %s: %s\n", ie.Event.PubKey, err.Error())
continue
}
jreq, _ := json.MarshalIndent(req, "", " ")
log("- got request from '%s': %s\n", color.New(color.Bold, color.FgBlue).Sprint(ie.Event.PubKey), string(jreq))
jresp, _ := json.MarshalIndent(resp, "", " ")
log("~ responding with %s\n", string(jresp))
handlerWg.Add(len(relayURLs))
for _, relayURL := range relayURLs {
go func(relayURL string) {
if relay, _ := sys.Pool.EnsureRelay(relayURL); relay != nil {
err := relay.Publish(ctx, eventResponse)
printLock.Lock()
if err == nil {
log("* sent response through %s\n", relay.URL)
} else {
log("* failed to send response: %s\n", err)
}
printLock.Unlock()
handlerWg.Done()
}
}(relayURL)
}
handlerWg.Wait()
// just after handling one request we trigger this
go func() {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
cancelPreviousBunkerInfoPrint = cancel
// the idea is that we will print the bunker URL again so it is easier to copy-paste by users
// but we will only do if the bunker is inactive for more than 5 minutes
select {
case <-ctx.Done():
case <-time.After(time.Minute * 5):
log("\n")
printBunkerInfo()
}
}()
}
return nil
},
}

View File

@@ -1,19 +1,22 @@
package main
import (
"encoding/json"
"errors"
"context"
"fmt"
"os"
"strings"
"github.com/fiatjaf/cli/v3"
"github.com/nbd-wtf/go-nostr"
"github.com/urfave/cli/v2"
"github.com/nbd-wtf/go-nostr/nip45"
"github.com/nbd-wtf/go-nostr/nip45/hyperloglog"
)
var count = &cli.Command{
Name: "count",
Usage: "generates encoded COUNT messages and optionally use them to talk to relays",
Description: `outputs a NIP-45 request (the flags are mostly the same as 'nak req').`,
Name: "count",
Usage: "generates encoded COUNT messages and optionally use them to talk to relays",
Description: `outputs a nip45 request (the flags are mostly the same as 'nak req').`,
DisableSliceFlagSeparator: true,
Flags: []cli.Flag{
&cli.StringSliceFlag{
Name: "author",
@@ -63,7 +66,33 @@ var count = &cli.Command{
},
},
ArgsUsage: "[relay...]",
Action: func(c *cli.Context) error {
Action: func(ctx context.Context, c *cli.Command) error {
biggerUrlSize := 0
relayUrls := c.Args().Slice()
if len(relayUrls) > 0 {
relays := connectToAllRelays(ctx,
relayUrls,
false,
)
if len(relays) == 0 {
log("failed to connect to any of the given relays.\n")
os.Exit(3)
}
relayUrls = make([]string, len(relays))
for i, relay := range relays {
relayUrls[i] = relay.URL
if len(relay.URL) > biggerUrlSize {
biggerUrlSize = len(relay.URL)
}
}
defer func() {
for _, relay := range relays {
relay.Close()
}
}()
}
filter := nostr.Filter{}
if authors := c.StringSlice("author"); len(authors) > 0 {
@@ -72,14 +101,18 @@ var count = &cli.Command{
if ids := c.StringSlice("id"); len(ids) > 0 {
filter.IDs = ids
}
if kinds := c.IntSlice("kind"); len(kinds) > 0 {
if kinds64 := c.IntSlice("kind"); len(kinds64) > 0 {
kinds := make([]int, len(kinds64))
for i, v := range kinds64 {
kinds[i] = int(v)
}
filter.Kinds = kinds
}
tags := make([][]string, 0, 5)
for _, tagFlag := range c.StringSlice("tag") {
spl := strings.Split(tagFlag, "=")
if len(spl) == 2 && len(spl[0]) == 1 {
spl := strings.SplitN(tagFlag, "=", 2)
if len(spl) == 2 {
tags = append(tags, spl)
} else {
return fmt.Errorf("invalid --tag '%s'", tagFlag)
@@ -110,36 +143,45 @@ var count = &cli.Command{
filter.Until = &ts
}
if limit := c.Int("limit"); limit != 0 {
filter.Limit = limit
filter.Limit = int(limit)
}
relays := c.Args().Slice()
successes := 0
failures := make([]error, 0, len(relays))
if len(relays) > 0 {
for _, relayUrl := range relays {
relay, err := nostr.RelayConnect(c.Context, relayUrl)
if len(relayUrls) > 0 {
var hll *hyperloglog.HyperLogLog
if offset := nip45.HyperLogLogEventPubkeyOffsetForFilter(filter); offset != -1 && len(relayUrls) > 1 {
hll = hyperloglog.New(offset)
}
for _, relayUrl := range relayUrls {
relay, _ := sys.Pool.EnsureRelay(relayUrl)
count, hllRegisters, err := relay.Count(ctx, nostr.Filters{filter})
fmt.Fprintf(os.Stderr, "%s%s: ", strings.Repeat(" ", biggerUrlSize-len(relayUrl)), relayUrl)
if err != nil {
failures = append(failures, err)
fmt.Fprintf(os.Stderr, "❌ %s\n", err)
continue
}
count, err := relay.Count(c.Context, nostr.Filters{filter})
if err != nil {
failures = append(failures, err)
continue
var hasHLLStr string
if hll != nil && len(hllRegisters) == 256 {
hll.MergeRegisters(hllRegisters)
hasHLLStr = " 📋"
}
fmt.Printf("%s: %d\n", relay.URL, count)
fmt.Fprintf(os.Stderr, "%d%s\n", count, hasHLLStr)
successes++
}
if successes == 0 {
return errors.Join(failures...)
return fmt.Errorf("all relays have failed")
} else if hll != nil {
fmt.Fprintf(os.Stderr, "📋 HyperLogLog sum: %d\n", hll.Count())
}
} else {
// no relays given, will just print the filter
var result string
j, _ := json.Marshal([]any{"COUNT", "nak", filter})
result = string(j)
fmt.Println(result)
stdout(result)
}
return nil

131
curl.go Normal file
View File

@@ -0,0 +1,131 @@
package main
import (
"context"
"encoding/base64"
"fmt"
"os"
"os/exec"
"strings"
"github.com/fiatjaf/cli/v3"
"github.com/nbd-wtf/go-nostr"
"golang.org/x/exp/slices"
)
var curlFlags []string
var curl = &cli.Command{
Name: "curl",
Usage: "calls curl but with a nip98 header",
Description: "accepts all flags and arguments exactly as they would be passed to curl.",
Flags: defaultKeyFlags,
Action: func(ctx context.Context, c *cli.Command) error {
kr, _, err := gatherKeyerFromArguments(ctx, c)
if err != nil {
return err
}
// cowboy parsing of curl flags to get the data we need for nip98
var url string
var method string
var presumedMethod string
curlBodyBuildingFlags := []string{
"-d",
"--data",
"--data-binary",
"--data-ascii",
"--data-raw",
"--data-urlencode",
"-F",
"--form",
"--form-string",
"--form-escape",
"--upload-file",
}
nextIsMethod := false
for _, f := range curlFlags {
if nextIsMethod {
method = f
method, _ = strings.CutPrefix(method, `"`)
method, _ = strings.CutSuffix(method, `"`)
method = strings.ToUpper(method)
} else if strings.HasPrefix(f, "https://") || strings.HasPrefix(f, "http://") {
url = f
} else if f == "--request" || f == "-X" {
nextIsMethod = true
continue
} else if slices.Contains(curlBodyBuildingFlags, f) ||
slices.ContainsFunc(curlBodyBuildingFlags, func(s string) bool {
return strings.HasPrefix(f, s)
}) {
presumedMethod = "POST"
}
nextIsMethod = false
}
if url == "" {
return fmt.Errorf("can't create nip98 event: target url is empty")
}
if method == "" {
if presumedMethod != "" {
method = presumedMethod
} else {
method = "GET"
}
}
// make and sign event
evt := nostr.Event{
Kind: 27235,
CreatedAt: nostr.Now(),
Tags: nostr.Tags{
{"u", url},
{"method", method},
},
}
if err := kr.SignEvent(ctx, &evt); err != nil {
return err
}
// the first 2 indexes of curlFlags were reserved for this
curlFlags[0] = "-H"
curlFlags[1] = fmt.Sprintf("Authorization: Nostr %s", base64.StdEncoding.EncodeToString([]byte(evt.String())))
// call curl
cmd := exec.Command("curl", curlFlags...)
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Run()
return nil
},
}
func realCurl() error {
curlFlags = make([]string, 2, max(len(os.Args)-4, 2))
keyFlags := make([]string, 0, 5)
for i := 0; i < len(os.Args[2:]); i++ {
arg := os.Args[i+2]
if slices.ContainsFunc(defaultKeyFlags, func(f cli.Flag) bool {
bareArg, _ := strings.CutPrefix(arg, "-")
bareArg, _ = strings.CutPrefix(bareArg, "-")
return slices.Contains(f.Names(), bareArg)
}) {
keyFlags = append(keyFlags, arg)
if arg != "--prompt-sec" {
i++
val := os.Args[i+2]
keyFlags = append(keyFlags, val)
}
} else {
curlFlags = append(curlFlags, arg)
}
}
return curl.Run(context.Background(), keyFlags)
}

View File

@@ -1,15 +1,14 @@
package main
import (
"context"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
"github.com/fiatjaf/cli/v3"
"github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/nip19"
sdk "github.com/nbd-wtf/nostr-sdk"
"github.com/urfave/cli/v2"
"github.com/nbd-wtf/go-nostr/sdk"
)
var decode = &cli.Command{
@@ -20,6 +19,7 @@ var decode = &cli.Command{
nak decode nevent1qqs29yet5tp0qq5xu5qgkeehkzqh5qu46739axzezcxpj4tjlkx9j7gpr4mhxue69uhkummnw3ez6ur4vgh8wetvd3hhyer9wghxuet5sh59ud
nak decode nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpz4mhxue69uhk2er9dchxummnw3ezumrpdejqz8thwden5te0dehhxarj94c82c3wwajkcmr0wfjx2u3wdejhgqgcwaehxw309aex2mrp0yhxummnw3exzarf9e3k7mgnp0sh5
nak decode nsec1jrmyhtjhgd9yqalps8hf9mayvd58852gtz66m7tqpacjedkp6kxq4dyxsr`,
DisableSliceFlagSeparator: true,
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "id",
@@ -33,8 +33,8 @@ var decode = &cli.Command{
},
},
ArgsUsage: "<npub | nprofile | nip05 | nevent | naddr | nsec>",
Action: func(c *cli.Context) error {
for input := range getStdinLinesOrFirstArgument(c) {
Action: func(ctx context.Context, c *cli.Command) error {
for input := range getStdinLinesOrArguments(c.Args()) {
if strings.HasPrefix(input, "nostr:") {
input = input[6:]
}
@@ -50,29 +50,44 @@ var decode = &cli.Command{
decodeResult.HexResult.PrivateKey = hex.EncodeToString(b)
decodeResult.HexResult.PublicKey = hex.EncodeToString(b)
} else {
lineProcessingError(c, "hex string with invalid number of bytes: %d", len(b))
ctx = lineProcessingError(ctx, "hex string with invalid number of bytes: %d", len(b))
continue
}
} else if evp := sdk.InputToEventPointer(input); evp != nil {
decodeResult = DecodeResult{EventPointer: evp}
} else if pp := sdk.InputToProfile(c.Context, input); pp != nil {
if c.Bool("id") {
stdout(evp.ID)
continue
}
} else if pp := sdk.InputToProfile(ctx, input); pp != nil {
decodeResult = DecodeResult{ProfilePointer: pp}
if c.Bool("pubkey") {
stdout(pp.PublicKey)
continue
}
} else if prefix, value, err := nip19.Decode(input); err == nil && prefix == "naddr" {
ep := value.(nostr.EntityPointer)
decodeResult = DecodeResult{EntityPointer: &ep}
if ep, ok := value.(nostr.EntityPointer); ok {
decodeResult = DecodeResult{EntityPointer: &ep}
} else {
ctx = lineProcessingError(ctx, "couldn't decode naddr: %s", err)
}
} else if prefix, value, err := nip19.Decode(input); err == nil && prefix == "nsec" {
decodeResult.PrivateKey.PrivateKey = value.(string)
decodeResult.PrivateKey.PublicKey, _ = nostr.GetPublicKey(value.(string))
} else {
lineProcessingError(c, "couldn't decode input '%s': %s", input, err)
ctx = lineProcessingError(ctx, "couldn't decode input '%s': %s", input, err)
continue
}
fmt.Println(decodeResult.JSON())
if c.Bool("pubkey") || c.Bool("id") {
return nil
}
stdout(decodeResult.JSON())
}
exitIfLineProcessingError(c)
exitIfLineProcessingError(ctx)
return nil
},
}

128
encode.go
View File

@@ -1,10 +1,12 @@
package main
import (
"context"
"fmt"
"github.com/fiatjaf/cli/v3"
"github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/nip19"
"github.com/urfave/cli/v2"
)
var encode = &cli.Command{
@@ -17,52 +19,55 @@ var encode = &cli.Command{
nak encode nevent <event-id>
nak encode nevent --author <pubkey-hex> --relay <relay-url> --relay <other-relay> <event-id>
nak encode nsec <privkey-hex>`,
Before: func(c *cli.Context) error {
Before: func(ctx context.Context, c *cli.Command) error {
if c.Args().Len() < 1 {
return fmt.Errorf("expected more than 1 argument.")
}
return nil
},
Subcommands: []*cli.Command{
DisableSliceFlagSeparator: true,
Commands: []*cli.Command{
{
Name: "npub",
Usage: "encode a hex public key into bech32 'npub' format",
Action: func(c *cli.Context) error {
for target := range getStdinLinesOrFirstArgument(c) {
if err := validate32BytesHex(target); err != nil {
lineProcessingError(c, "invalid public key: %s", target, err)
Name: "npub",
Usage: "encode a hex public key into bech32 'npub' format",
DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error {
for target := range getStdinLinesOrArguments(c.Args()) {
if ok := nostr.IsValidPublicKey(target); !ok {
ctx = lineProcessingError(ctx, "invalid public key: %s", target)
continue
}
if npub, err := nip19.EncodePublicKey(target); err == nil {
fmt.Println(npub)
stdout(npub)
} else {
return err
}
}
exitIfLineProcessingError(c)
exitIfLineProcessingError(ctx)
return nil
},
},
{
Name: "nsec",
Usage: "encode a hex private key into bech32 'nsec' format",
Action: func(c *cli.Context) error {
for target := range getStdinLinesOrFirstArgument(c) {
if err := validate32BytesHex(target); err != nil {
lineProcessingError(c, "invalid private key: %s", target, err)
Name: "nsec",
Usage: "encode a hex private key into bech32 'nsec' format",
DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error {
for target := range getStdinLinesOrArguments(c.Args()) {
if ok := nostr.IsValid32ByteHex(target); !ok {
ctx = lineProcessingError(ctx, "invalid private key: %s", target)
continue
}
if npub, err := nip19.EncodePrivateKey(target); err == nil {
fmt.Println(npub)
stdout(npub)
} else {
return err
}
}
exitIfLineProcessingError(c)
exitIfLineProcessingError(ctx)
return nil
},
},
@@ -76,26 +81,27 @@ var encode = &cli.Command{
Usage: "attach relay hints to nprofile code",
},
},
Action: func(c *cli.Context) error {
for target := range getStdinLinesOrFirstArgument(c) {
if err := validate32BytesHex(target); err != nil {
lineProcessingError(c, "invalid public key: %s", target, err)
DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error {
for target := range getStdinLinesOrArguments(c.Args()) {
if ok := nostr.IsValid32ByteHex(target); !ok {
ctx = lineProcessingError(ctx, "invalid public key: %s", target)
continue
}
relays := c.StringSlice("relay")
if err := validateRelayURLs(relays); err != nil {
if err := normalizeAndValidateRelayURLs(relays); err != nil {
return err
}
if npub, err := nip19.EncodeProfile(target, relays); err == nil {
fmt.Println(npub)
stdout(npub)
} else {
return err
}
}
exitIfLineProcessingError(c)
exitIfLineProcessingError(ctx)
return nil
},
},
@@ -109,43 +115,45 @@ var encode = &cli.Command{
Usage: "attach relay hints to nevent code",
},
&cli.StringFlag{
Name: "author",
Usage: "attach an author pubkey as a hint to the nevent code",
Name: "author",
Aliases: []string{"a"},
Usage: "attach an author pubkey as a hint to the nevent code",
},
},
Action: func(c *cli.Context) error {
for target := range getStdinLinesOrFirstArgument(c) {
if err := validate32BytesHex(target); err != nil {
lineProcessingError(c, "invalid event id: %s", target, err)
DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error {
for target := range getStdinLinesOrArguments(c.Args()) {
if ok := nostr.IsValid32ByteHex(target); !ok {
ctx = lineProcessingError(ctx, "invalid event id: %s", target)
continue
}
author := c.String("author")
if author != "" {
if err := validate32BytesHex(author); err != nil {
return err
if ok := nostr.IsValidPublicKey(author); !ok {
return fmt.Errorf("invalid 'author' public key")
}
}
relays := c.StringSlice("relay")
if err := validateRelayURLs(relays); err != nil {
if err := normalizeAndValidateRelayURLs(relays); err != nil {
return err
}
if npub, err := nip19.EncodeEvent(target, relays, author); err == nil {
fmt.Println(npub)
stdout(npub)
} else {
return err
}
}
exitIfLineProcessingError(c)
exitIfLineProcessingError(ctx)
return nil
},
},
{
Name: "naddr",
Usage: "generate codes for NIP-33 parameterized replaceable events",
Usage: "generate codes for addressable events",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "identifier",
@@ -156,10 +164,10 @@ var encode = &cli.Command{
&cli.StringFlag{
Name: "pubkey",
Usage: "pubkey of the naddr author",
Aliases: []string{"p"},
Aliases: []string{"author", "a", "p"},
Required: true,
},
&cli.Int64Flag{
&cli.IntFlag{
Name: "kind",
Aliases: []string{"k"},
Usage: "kind of referred replaceable event",
@@ -171,60 +179,40 @@ var encode = &cli.Command{
Usage: "attach relay hints to naddr code",
},
},
Action: func(c *cli.Context) error {
DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error {
for d := range getStdinLinesOrBlank() {
pubkey := c.String("pubkey")
if err := validate32BytesHex(pubkey); err != nil {
return err
if ok := nostr.IsValidPublicKey(pubkey); !ok {
return fmt.Errorf("invalid 'pubkey'")
}
kind := c.Int("kind")
if kind < 30000 || kind >= 40000 {
return fmt.Errorf("kind must be between 30000 and 39999, as per NIP-16, got %d", kind)
return fmt.Errorf("kind must be between 30000 and 39999, got %d", kind)
}
if d == "" {
d = c.String("identifier")
if d == "" {
lineProcessingError(c, "\"d\" tag identifier can't be empty")
ctx = lineProcessingError(ctx, "\"d\" tag identifier can't be empty")
continue
}
}
relays := c.StringSlice("relay")
if err := validateRelayURLs(relays); err != nil {
if err := normalizeAndValidateRelayURLs(relays); err != nil {
return err
}
if npub, err := nip19.EncodeEntity(pubkey, kind, d, relays); err == nil {
fmt.Println(npub)
if npub, err := nip19.EncodeEntity(pubkey, int(kind), d, relays); err == nil {
stdout(npub)
} else {
return err
}
}
exitIfLineProcessingError(c)
return nil
},
},
{
Name: "note",
Usage: "generate note1 event codes (not recommended)",
Action: func(c *cli.Context) error {
for target := range getStdinLinesOrFirstArgument(c) {
if err := validate32BytesHex(target); err != nil {
lineProcessingError(c, "invalid event id: %s", target, err)
continue
}
if note, err := nip19.EncodeNote(target); err == nil {
fmt.Println(note)
} else {
return err
}
}
exitIfLineProcessingError(c)
exitIfLineProcessingError(ctx)
return nil
},
},

140
encrypt_decrypt.go Normal file
View File

@@ -0,0 +1,140 @@
package main
import (
"context"
"fmt"
"github.com/fiatjaf/cli/v3"
"github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/nip04"
)
var encrypt = &cli.Command{
Name: "encrypt",
Usage: "encrypts a string with nip44 (or nip04 if specified using a flag) and returns the resulting ciphertext as base64",
ArgsUsage: "[plaintext string]",
DisableSliceFlagSeparator: true,
Flags: append(
defaultKeyFlags,
&cli.StringFlag{
Name: "recipient-pubkey",
Aliases: []string{"p", "tgt", "target", "pubkey"},
Required: true,
},
&cli.BoolFlag{
Name: "nip04",
Usage: "use nip04 encryption instead of nip44",
},
),
Action: func(ctx context.Context, c *cli.Command) error {
target := c.String("recipient-pubkey")
if !nostr.IsValidPublicKey(target) {
return fmt.Errorf("target %s is not a valid public key", target)
}
plaintext := c.Args().First()
if c.Bool("nip04") {
sec, bunker, err := gatherSecretKeyOrBunkerFromArguments(ctx, c)
if err != nil {
return err
}
if bunker != nil {
ciphertext, err := bunker.NIP04Encrypt(ctx, target, plaintext)
if err != nil {
return err
}
stdout(ciphertext)
} else {
ss, err := nip04.ComputeSharedSecret(target, sec)
if err != nil {
return fmt.Errorf("failed to compute nip04 shared secret: %w", err)
}
ciphertext, err := nip04.Encrypt(plaintext, ss)
if err != nil {
return fmt.Errorf("failed to encrypt as nip04: %w", err)
}
stdout(ciphertext)
}
} else {
kr, _, err := gatherKeyerFromArguments(ctx, c)
if err != nil {
return err
}
res, err := kr.Encrypt(ctx, plaintext, target)
if err != nil {
return fmt.Errorf("failed to encrypt: %w", err)
}
stdout(res)
}
return nil
},
}
var decrypt = &cli.Command{
Name: "decrypt",
Usage: "decrypts a base64 nip44 ciphertext (or nip04 if specified using a flag) and returns the resulting plaintext",
ArgsUsage: "[ciphertext base64]",
DisableSliceFlagSeparator: true,
Flags: append(
defaultKeyFlags,
&cli.StringFlag{
Name: "sender-pubkey",
Aliases: []string{"p", "src", "source", "pubkey"},
Required: true,
},
&cli.BoolFlag{
Name: "nip04",
Usage: "use nip04 encryption instead of nip44",
},
),
Action: func(ctx context.Context, c *cli.Command) error {
source := c.String("sender-pubkey")
if !nostr.IsValidPublicKey(source) {
return fmt.Errorf("source %s is not a valid public key", source)
}
ciphertext := c.Args().First()
if c.Bool("nip04") {
sec, bunker, err := gatherSecretKeyOrBunkerFromArguments(ctx, c)
if err != nil {
return err
}
if bunker != nil {
plaintext, err := bunker.NIP04Decrypt(ctx, source, ciphertext)
if err != nil {
return err
}
stdout(plaintext)
} else {
ss, err := nip04.ComputeSharedSecret(source, sec)
if err != nil {
return fmt.Errorf("failed to compute nip04 shared secret: %w", err)
}
plaintext, err := nip04.Decrypt(ciphertext, ss)
if err != nil {
return fmt.Errorf("failed to encrypt as nip04: %w", err)
}
stdout(plaintext)
}
} else {
kr, _, err := gatherKeyerFromArguments(ctx, c)
if err != nil {
return err
}
res, err := kr.Decrypt(ctx, ciphertext, source)
if err != nil {
return fmt.Errorf("failed to encrypt: %w", err)
}
stdout(res)
}
return nil
},
}

327
event.go
View File

@@ -2,23 +2,24 @@ package main
import (
"context"
"encoding/json"
"fmt"
"os"
"strconv"
"slices"
"strings"
"time"
"github.com/bgentry/speakeasy"
"github.com/fiatjaf/cli/v3"
"github.com/mailru/easyjson"
"github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/nip13"
"github.com/nbd-wtf/go-nostr/nip19"
"github.com/nbd-wtf/go-nostr/nson"
"github.com/urfave/cli/v2"
"golang.org/x/exp/slices"
)
const CATEGORY_EVENT_FIELDS = "EVENT FIELDS"
const (
CATEGORY_EVENT_FIELDS = "EVENT FIELDS"
CATEGORY_SIGNER = "SIGNER OPTIONS"
CATEGORY_EXTRAS = "EXTRAS"
)
var event = &cli.Command{
Name: "event",
@@ -33,28 +34,56 @@ if an event -- or a partial event -- is given on stdin, the flags can be used to
example:
echo '{"id":"a889df6a387419ff204305f4c2d296ee328c3cd4f8b62f205648a541b4554dfb","pubkey":"c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5","created_at":1698623783,"kind":1,"tags":[],"content":"hello from the nostr army knife","sig":"84876e1ee3e726da84e5d195eb79358b2b3eaa4d9bd38456fde3e8a2af3f1cd4cda23f23fda454869975b3688797d4c66e12f4c51c1b43c6d2997c5e61865661"}' | nak event wss://offchain.pub
echo '{"tags": [["t", "spam"]]}' | nak event -c 'this is spam'
`,
Flags: []cli.Flag{
echo '{"tags": [["t", "spam"]]}' | nak event -c 'this is spam'`,
DisableSliceFlagSeparator: true,
Flags: append(defaultKeyFlags,
// ~ these args are only for the convoluted musig2 signing process
// they will be generally copy-shared-pasted across some manual coordination method between participants
&cli.UintFlag{
Name: "musig",
Usage: "number of signers to use for musig2",
Value: 1,
DefaultText: "1 -- i.e. do not use musig2 at all",
Category: CATEGORY_SIGNER,
},
&cli.StringSliceFlag{
Name: "musig-pubkey",
Hidden: true,
},
&cli.StringFlag{
Name: "sec",
Usage: "secret key to sign the event, as hex or nsec",
DefaultText: "the key '1'",
Value: "0000000000000000000000000000000000000000000000000000000000000001",
Name: "musig-nonce-secret",
Hidden: true,
},
&cli.StringSliceFlag{
Name: "musig-nonce",
Hidden: true,
},
&cli.StringSliceFlag{
Name: "musig-partial",
Hidden: true,
},
// ~~~
&cli.UintFlag{
Name: "pow",
Usage: "nip13 difficulty to target when doing hash work on the event id",
Category: CATEGORY_EXTRAS,
},
&cli.BoolFlag{
Name: "prompt-sec",
Usage: "prompt the user to paste a hex or nsec with which to sign the event",
Name: "envelope",
Usage: "print the event enveloped in a [\"EVENT\", ...] message ready to be sent to a relay",
Category: CATEGORY_EXTRAS,
},
&cli.BoolFlag{
Name: "envelope",
Usage: "print the event enveloped in a [\"EVENT\", ...] message ready to be sent to a relay",
Name: "auth",
Usage: "always perform nip42 \"AUTH\" when facing an \"auth-required: \" rejection and try again",
Category: CATEGORY_EXTRAS,
},
&cli.BoolFlag{
Name: "nson",
Usage: "encode the event using NSON",
Name: "nevent",
Usage: "print the nevent code (to stderr) after the event is published",
Category: CATEGORY_EXTRAS,
},
&cli.IntFlag{
&cli.UintFlag{
Name: "kind",
Aliases: []string{"k"},
Usage: "event kind",
@@ -73,7 +102,7 @@ example:
&cli.StringSliceFlag{
Name: "tag",
Aliases: []string{"t"},
Usage: "sets a tag field on the event, takes a value like -t e=<id>",
Usage: "sets a tag field on the event, takes a value like -t e=<id> or -t sometag=\"value one;value two;value three\"",
Category: CATEGORY_EVENT_FIELDS,
},
&cli.StringSliceFlag{
@@ -86,94 +115,99 @@ example:
Usage: "shortcut for --tag p=<value>",
Category: CATEGORY_EVENT_FIELDS,
},
&cli.StringFlag{
&cli.StringSliceFlag{
Name: "d",
Usage: "shortcut for --tag d=<value>",
Category: CATEGORY_EVENT_FIELDS,
},
&NaturalTimeFlag{
Name: "created-at",
Aliases: []string{"time", "ts"},
Usage: "unix timestamp value for the created_at field",
DefaultText: "now",
Value: "",
Value: nostr.Now(),
Category: CATEGORY_EVENT_FIELDS,
},
},
),
ArgsUsage: "[relay...]",
Action: func(c *cli.Context) error {
// gather the secret key first
sec := c.String("sec")
if c.Bool("prompt-sec") {
if isPiped() {
return fmt.Errorf("can't prompt for a secret key when processing data from a pipe, try again without --prompt-sec")
Action: func(ctx context.Context, c *cli.Command) error {
// try to connect to the relays here
var relays []*nostr.Relay
if relayUrls := c.Args().Slice(); len(relayUrls) > 0 {
relays = connectToAllRelays(ctx, relayUrls, false)
if len(relays) == 0 {
log("failed to connect to any of the given relays.\n")
os.Exit(3)
}
var err error
sec, err = speakeasy.FAsk(os.Stderr, "type your secret key as nsec or hex: ")
if err != nil {
return fmt.Errorf("failed to get secret key: %w", err)
}
}
if strings.HasPrefix(sec, "nsec1") {
_, hex, err := nip19.Decode(sec)
if err != nil {
return fmt.Errorf("invalid nsec: %w", err)
}
sec = hex.(string)
}
if len(sec) > 64 {
return fmt.Errorf("invalid secret key: too large")
}
sec = strings.Repeat("0", 64-len(sec)) + sec // left-pad
if err := validate32BytesHex(sec); err != nil {
return fmt.Errorf("invalid secret key")
}
// then process input and generate events
for stdinEvent := range getStdinLinesOrBlank() {
evt := nostr.Event{
Tags: make(nostr.Tags, 0, 3),
defer func() {
for _, relay := range relays {
relay.Close()
}
}()
kindWasSupplied := true
kr, sec, err := gatherKeyerFromArguments(ctx, c)
if err != nil {
return err
}
doAuth := c.Bool("auth")
// then process input and generate events:
// will reuse this
var evt nostr.Event
// this is called when we have a valid json from stdin
handleEvent := func(stdinEvent string) error {
evt.Content = ""
kindWasSupplied := strings.Contains(stdinEvent, `"kind"`)
mustRehashAndResign := false
if stdinEvent != "" {
if err := json.Unmarshal([]byte(stdinEvent), &evt); err != nil {
lineProcessingError(c, "invalid event received from stdin: %s", err)
continue
}
kindWasSupplied = strings.Contains(stdinEvent, `"kind"`)
}
kindWasSupplied = slices.Contains(c.FlagNames(), "kind")
if kind := c.Int("kind"); kindWasSupplied {
evt.Kind = kind
if err := easyjson.Unmarshal([]byte(stdinEvent), &evt); err != nil {
return fmt.Errorf("invalid event received from stdin: %s", err)
}
if kind := c.Uint("kind"); slices.Contains(c.FlagNames(), "kind") {
evt.Kind = int(kind)
mustRehashAndResign = true
} else if !kindWasSupplied {
evt.Kind = 1
mustRehashAndResign = true
}
if content := c.String("content"); content != "" {
evt.Content = content
if c.IsSet("content") {
evt.Content = c.String("content")
mustRehashAndResign = true
} else if evt.Content == "" && evt.Kind == 1 {
evt.Content = "hello from the nostr army knife"
mustRehashAndResign = true
}
tags := make(nostr.Tags, 0, 5)
for _, tagFlag := range c.StringSlice("tag") {
tagFlags := c.StringSlice("tag")
tags := make(nostr.Tags, 0, len(tagFlags)+2)
for _, tagFlag := range tagFlags {
// tags are in the format key=value
spl := strings.Split(tagFlag, "=")
if len(spl) == 2 && len(spl[0]) > 0 {
tag := nostr.Tag{spl[0]}
tagName, tagValue, found := strings.Cut(tagFlag, "=")
tag := []string{tagName}
if found {
// tags may also contain extra elements separated with a ";"
spl2 := strings.Split(spl[1], ";")
tag = append(tag, spl2...)
// ~
tags = append(tags, tag)
tagValues := strings.Split(tagValue, ";")
tag = append(tag, tagValues...)
}
tags = append(tags, tag)
}
for _, etag := range c.StringSlice("e") {
tags = append(tags, []string{"e", etag})
mustRehashAndResign = true
tags = tags.AppendUnique([]string{"e", etag})
}
for _, ptag := range c.StringSlice("p") {
tags = append(tags, []string{"p", ptag})
mustRehashAndResign = true
tags = tags.AppendUnique([]string{"p", ptag})
}
for _, dtag := range c.StringSlice("d") {
tags = tags.AppendUnique([]string{"d", dtag})
}
if len(tags) > 0 {
for _, tag := range tags {
@@ -182,61 +216,124 @@ example:
mustRehashAndResign = true
}
if createdAt := c.String("created-at"); createdAt != "" {
ts := time.Now()
if createdAt != "now" {
if v, err := strconv.ParseInt(createdAt, 10, 64); err != nil {
return fmt.Errorf("failed to parse timestamp '%s': %w", createdAt, err)
} else {
ts = time.Unix(v, 0)
}
}
evt.CreatedAt = nostr.Timestamp(ts.Unix())
if c.IsSet("created-at") {
evt.CreatedAt = getNaturalDate(c, "created-at")
mustRehashAndResign = true
} else if evt.CreatedAt == 0 {
evt.CreatedAt = nostr.Now()
mustRehashAndResign = true
}
if c.IsSet("musig") || c.IsSet("sec") || c.IsSet("prompt-sec") {
mustRehashAndResign = true
}
if difficulty := c.Uint("pow"); difficulty > 0 {
// before doing pow we need the pubkey
if numSigners := c.Uint("musig"); numSigners > 1 {
pubkeys := c.StringSlice("musig-pubkey")
if int(numSigners) != len(pubkeys) {
return fmt.Errorf("when doing a pow with musig we must know all signer pubkeys upfront")
}
evt.PubKey, err = getMusigAggregatedKey(ctx, pubkeys)
if err != nil {
return err
}
} else {
evt.PubKey, _ = kr.GetPublicKey(ctx)
}
// try to generate work with this difficulty -- runs forever
nonceTag, _ := nip13.DoWork(ctx, evt, int(difficulty))
evt.Tags = append(evt.Tags, nonceTag)
mustRehashAndResign = true
}
if evt.Sig == "" || mustRehashAndResign {
if err := evt.Sign(sec); err != nil {
if numSigners := c.Uint("musig"); numSigners > 1 {
// must do musig
pubkeys := c.StringSlice("musig-pubkey")
secNonce := c.String("musig-nonce-secret")
pubNonces := c.StringSlice("musig-nonce")
partialSigs := c.StringSlice("musig-partial")
signed, err := performMusig(ctx,
sec, &evt, int(numSigners), pubkeys, pubNonces, secNonce, partialSigs)
if err != nil {
return fmt.Errorf("musig error: %w", err)
}
if !signed {
// we haven't finished signing the event, so the users still have to do more steps
// instructions for what to do should have been printed by the performMusig() function
return nil
}
} else if err := kr.SignEvent(ctx, &evt); err != nil {
return fmt.Errorf("error signing with provided key: %w", err)
}
}
relays := c.Args().Slice()
// print event as json
var result string
if c.Bool("envelope") {
j, _ := json.Marshal(nostr.EventEnvelope{Event: evt})
result = string(j)
} else {
j, _ := easyjson.Marshal(&evt)
result = string(j)
}
stdout(result)
// publish to relays
successRelays := make([]string, 0, len(relays))
if len(relays) > 0 {
fmt.Println(evt.String())
for _, url := range relays {
fmt.Fprintf(os.Stderr, "publishing to %s... ", url)
if relay, err := nostr.RelayConnect(c.Context, url); err != nil {
fmt.Fprintf(os.Stderr, "failed to connect: %s\n", err)
} else {
ctx, cancel := context.WithTimeout(c.Context, 10*time.Second)
defer cancel()
if status, err := relay.Publish(ctx, evt); err != nil {
fmt.Fprintf(os.Stderr, "failed: %s\n", err)
os.Stdout.Sync()
for _, relay := range relays {
publish:
log("publishing to %s... ", relay.URL)
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
err := relay.Publish(ctx, evt)
if err == nil {
// published fine
log("success.\n")
successRelays = append(successRelays, relay.URL)
continue // continue to next relay
}
// error publishing
if strings.HasPrefix(err.Error(), "msg: auth-required:") && kr != nil && doAuth {
// if the relay is requesting auth and we can auth, let's do it
pk, _ := kr.GetPublicKey(ctx)
log("performing auth as %s... ", pk)
if err := relay.Auth(ctx, func(authEvent *nostr.Event) error {
return kr.SignEvent(ctx, authEvent)
}); err == nil {
// try to publish again, but this time don't try to auth again
doAuth = false
goto publish
} else {
fmt.Fprintf(os.Stderr, "%s.\n", status)
log("auth error: %s. ", err)
}
}
log("failed: %s\n", err)
}
} else {
var result string
if c.Bool("envelope") {
j, _ := json.Marshal([]any{"EVENT", evt})
result = string(j)
} else if c.Bool("nson") {
result, _ = nson.Marshal(&evt)
} else {
j, _ := easyjson.Marshal(&evt)
result = string(j)
if len(successRelays) > 0 && c.Bool("nevent") {
nevent, _ := nip19.EncodeEvent(evt.ID, successRelays, evt.PubKey)
log(nevent + "\n")
}
fmt.Println(result)
}
return nil
}
for stdinEvent := range getJsonsOrBlank() {
if err := handleEvent(stdinEvent); err != nil {
ctx = lineProcessingError(ctx, err.Error())
}
}
exitIfLineProcessingError(c)
exitIfLineProcessingError(ctx)
return nil
},
}

View File

@@ -1,31 +1,138 @@
package main
import (
"context"
)
// these tests are tricky because commands and flags are declared as globals and values set in one call may persist
// to the next. for example, if in the first test we set --limit 2 then doesn't specify --limit in the second then
// it will still return true for cmd.IsSet("limit") and then we will set .LimitZero = true
var ctx = context.Background()
func ExampleEventBasic() {
app.Run([]string{"nak", "event", "--ts", "1699485669"})
app.Run(ctx, []string{"nak", "event", "--ts", "1699485669"})
// Output:
// {"id":"36d88cf5fcc449f2390a424907023eda7a74278120eebab8d02797cd92e7e29c","pubkey":"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798","created_at":1699485669,"kind":1,"tags":[],"content":"hello from the nostr army knife","sig":"68e71a192e8abcf8582a222434ac823ecc50607450ebe8cc4c145eb047794cc382dc3f888ce879d2f404f5ba6085a47601360a0fa2dd4b50d317bd0c6197c2c2"}
// {"kind":1,"id":"36d88cf5fcc449f2390a424907023eda7a74278120eebab8d02797cd92e7e29c","pubkey":"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798","created_at":1699485669,"tags":[],"content":"hello from the nostr army knife","sig":"68e71a192e8abcf8582a222434ac823ecc50607450ebe8cc4c145eb047794cc382dc3f888ce879d2f404f5ba6085a47601360a0fa2dd4b50d317bd0c6197c2c2"}
}
// (for some reason there can only be one test dealing with stdin in the suite otherwise it halts)
// func ExampleEventParsingFromStdin() {
// prevStdin := os.Stdin
// defer func() { os.Stdin = prevStdin }()
// r, w, _ := os.Pipe()
// os.Stdin = r
// w.WriteString("{\"content\":\"hello world\"}\n{\"content\":\"hello sun\"}\n")
// app.Run(ctx, []string{"nak", "event", "-t", "t=spam", "--ts", "1699485669"})
// // Output:
// // {"id":"bda134f9077c11973afe6aa5a1cc6f5bcea01c40d318b8f91dcb8e50507cfa52","pubkey":"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798","created_at":1699485669,"kind":1,"tags":[["t","spam"]],"content":"hello world","sig":"7552454bb8e7944230142634e3e34ac7468bad9b21ed6909da572c611018dff1d14d0792e98b5806f6330edc51e09efa6d0b66a9694dc34606c70f4e580e7493"}
// // {"id":"879c36ec73acca288825b53585389581d3836e7f0fe4d46e5eba237ca56d6af5","pubkey":"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798","created_at":1699485669,"kind":1,"tags":[["t","spam"]],"content":"hello sun","sig":"6c7e6b13ebdf931d26acfdd00bec2ec1140ddaf8d1ed61453543a14e729a460fe36c40c488ccb194a0e1ab9511cb6c36741485f501bdb93c39ca4c51bc59cbd4"}
// }
func ExampleEventComplex() {
app.Run([]string{"nak", "event", "--ts", "1699485669", "-k", "11", "-c", "skjdbaskd", "--sec", "17", "-t", "t=spam", "-e", "36d88cf5fcc449f2390a424907023eda7a74278120eebab8d02797cd92e7e29c", "-t", "r=https://abc.def;nothing"})
app.Run(ctx, []string{"nak", "event", "--ts", "1699485669", "-k", "11", "-c", "skjdbaskd", "--sec", "17", "-t", "t=spam", "-e", "36d88cf5fcc449f2390a424907023eda7a74278120eebab8d02797cd92e7e29c", "-t", "r=https://abc.def?name=foobar;nothing"})
// Output:
// {"id":"aec4de6d051a7c2b6ca2d087903d42051a31e07fb742f1240970084822de10a6","pubkey":"2fa2104d6b38d11b0230010559879124e42ab8dfeff5ff29dc9cdadd4ecacc3f","created_at":1699485669,"kind":11,"tags":[["t","spam"],["r","https://abc.def","nothing"],["e","36d88cf5fcc449f2390a424907023eda7a74278120eebab8d02797cd92e7e29c"]],"content":"skjdbaskd","sig":"1165ac7a27d774d351ef19c8e918fb22f4005fcba193976c3d7edba6ef87ead7f14467f376a9e199f8371835368d86a8506f591e382528d00287fb168a7b8f38"}
// {"kind":11,"id":"19aba166dcf354bf5ef64f4afe69ada1eb851495001ee05e07d393ee8c8ea179","pubkey":"2fa2104d6b38d11b0230010559879124e42ab8dfeff5ff29dc9cdadd4ecacc3f","created_at":1699485669,"tags":[["t","spam"],["r","https://abc.def?name=foobar","nothing"],["e","36d88cf5fcc449f2390a424907023eda7a74278120eebab8d02797cd92e7e29c"]],"content":"skjdbaskd","sig":"cf452def4a68341c897c3fc96fa34dc6895a5b8cc266d4c041bcdf758ec992ec5adb8b0179e98552aaaf9450526a26d7e62e413b15b1c57e0cfc8db6b29215d7"}
}
func ExampleEncode() {
app.Run(ctx, []string{"nak", "encode", "npub", "a6a67ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f8179822"})
app.Run(ctx, []string{"nak", "encode", "nprofile", "-r", "wss://example.com", "a6a67ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f8179822"})
app.Run(ctx, []string{"nak", "encode", "nprofile", "-r", "wss://example.com", "a6a67ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f8179822", "a5592173975ded9f836a9572ea8b11a7e16ceb66464d66d50b27163f7f039d2c"})
// npub156n8a7wuhwk9tgrzjh8gwzc8q2dlekedec5djk0js9d3d7qhnq3qjpdq28
// nprofile1qqs2dfn7l8wthtz45p3ftn58pvrs9xlumvkuu2xet8egzkcklqtesgspz9mhxue69uhk27rpd4cxcefwvdhk6fl5jug
// nprofile1qqs2dfn7l8wthtz45p3ftn58pvrs9xlumvkuu2xet8egzkcklqtesgspz9mhxue69uhk27rpd4cxcefwvdhk6fl5jug
// nprofile1qqs22kfpwwt4mmvlsd4f2uh23vg60ctvadnyvntx659jw93l0upe6tqpz9mhxue69uhk27rpd4cxcefwvdhk64h265a
}
func ExampleDecode() {
app.Run(ctx, []string{"nak", "decode", "naddr1qqyrgcmyxe3kvefhqyxhwumn8ghj7mn0wvhxcmmvqgs9kqvr4dkruv3t7n2pc6e6a7v9v2s5fprmwjv4gde8c4fe5y29v0srqsqqql9ngrt6tu", "nevent1qyd8wumn8ghj7urewfsk66ty9enxjct5dfskvtnrdakj7qgmwaehxw309aex2mrp0yh8wetnw3jhymnzw33jucm0d5hszxthwden5te0wfjkccte9eekummjwsh8xmmrd9skctcpzamhxue69uhkzarvv9ejumn0wd68ytnvv9hxgtcqyqllp5v5j0nxr74fptqxkhvfv0h3uj870qpk3ln8a58agyxl3fka296ewr8"})
// Output:
// {
// "pubkey": "5b0183ab6c3e322bf4d41c6b3aef98562a144847b7499543727c5539a114563e",
// "kind": 31923,
// "identifier": "4cd6cfe7",
// "relays": [
// "wss://nos.lol"
// ]
// }
// {
// "id": "3ff0d19493e661faa90ac06b5d8963ef1e48fe780368fe67ed0fd410df8a6dd5",
// "relays": [
// "wss://pyramid.fiatjaf.com/",
// "wss://relay.westernbtc.com/",
// "wss://relay.snort.social/",
// "wss://atlas.nostr.land/"
// ]
// }
}
func ExampleDecodePubkey() {
app.Run(ctx, []string{"nak", "decode", "-p", "npub10xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqpkge6d", "npub1ccz8l9zpa47k6vz9gphftsrumpw80rjt3nhnefat4symjhrsnmjs38mnyd"})
// Output:
// 79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798
// c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5
}
func ExampleDecodeEventId() {
app.Run(ctx, []string{"nak", "decode", "-e", "nevent1qyd8wumn8ghj7urewfsk66ty9enxjct5dfskvtnrdakj7qgmwaehxw309aex2mrp0yh8wetnw3jhymnzw33jucm0d5hszxthwden5te0wfjkccte9eekummjwsh8xmmrd9skctcpzamhxue69uhkzarvv9ejumn0wd68ytnvv9hxgtcqyqllp5v5j0nxr74fptqxkhvfv0h3uj870qpk3ln8a58agyxl3fka296ewr8", "nevent1qqswh48lurxs8u0pll9qj2rzctvjncwhstpzlstq59rdtzlty79awns5hl5uf"})
// Output:
// 3ff0d19493e661faa90ac06b5d8963ef1e48fe780368fe67ed0fd410df8a6dd5
// ebd4ffe0cd03f1e1ffca092862c2d929e1d782c22fc160a146d58beb278bd74e
}
func ExampleReq() {
app.Run([]string{"nak", "req", "-k", "1", "-l", "18", "-a", "2fa2104d6b38d11b0230010559879124e42ab8dfeff5ff29dc9cdadd4ecacc3f", "-e", "aec4de6d051a7c2b6ca2d087903d42051a31e07fb742f1240970084822de10a6"})
app.Run(ctx, []string{"nak", "req", "-k", "1", "-l", "18", "-a", "2fa2104d6b38d11b0230010559879124e42ab8dfeff5ff29dc9cdadd4ecacc3f", "-e", "aec4de6d051a7c2b6ca2d087903d42051a31e07fb742f1240970084822de10a6"})
// Output:
// ["REQ","nak",{"kinds":[1],"authors":["2fa2104d6b38d11b0230010559879124e42ab8dfeff5ff29dc9cdadd4ecacc3f"],"limit":18,"#e":["aec4de6d051a7c2b6ca2d087903d42051a31e07fb742f1240970084822de10a6"]}]
}
func ExampleEncodeNpub() {
app.Run([]string{"nak", "encode", "npub", "a6a67ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f8179822"})
func ExampleMultipleFetch() {
app.Run(ctx, []string{"nak", "fetch", "naddr1qqyrgcmyxe3kvefhqyxhwumn8ghj7mn0wvhxcmmvqgs9kqvr4dkruv3t7n2pc6e6a7v9v2s5fprmwjv4gde8c4fe5y29v0srqsqqql9ngrt6tu", "nevent1qyd8wumn8ghj7urewfsk66ty9enxjct5dfskvtnrdakj7qgmwaehxw309aex2mrp0yh8wetnw3jhymnzw33jucm0d5hszxthwden5te0wfjkccte9eekummjwsh8xmmrd9skctcpzamhxue69uhkzarvv9ejumn0wd68ytnvv9hxgtcqyqllp5v5j0nxr74fptqxkhvfv0h3uj870qpk3ln8a58agyxl3fka296ewr8"})
// Output:
// npub156n8a7wuhwk9tgrzjh8gwzc8q2dlekedec5djk0js9d3d7qhnq3qjpdq28
// {"kind":31923,"id":"9ae5014573fc75ced00b343868d2cd9343ebcbbae50591c6fa8ae1cd99568f05","pubkey":"5b0183ab6c3e322bf4d41c6b3aef98562a144847b7499543727c5539a114563e","created_at":1707764605,"tags":[["d","4cd6cfe7"],["name","Nostr PHX Presents Culture Shock"],["description","Nostr PHX presents Culture Shock the first Value 4 Value Cultural Event in Downtown Phoenix. We will showcase the power of Nostr + Bitcoin / Lightning with a full day of education, food, drinks, conversation, vendors and best of all, a live convert which will stream globally for the world to zap. "],["start","1708185600"],["end","1708228800"],["start_tzid","America/Phoenix"],["p","5b0183ab6c3e322bf4d41c6b3aef98562a144847b7499543727c5539a114563e","","host"],["location","Hello Merch, 850 W Lincoln St, Phoenix, AZ 85007, USA","Hello Merch","850 W Lincoln St, Phoenix, AZ 85007, USA"],["address","Hello Merch, 850 W Lincoln St, Phoenix, AZ 85007, USA","Hello Merch","850 W Lincoln St, Phoenix, AZ 85007, USA"],["g","9tbq1rzn"],["image","https://flockstr.s3.amazonaws.com/event/15vSaiscDhVH1KBXhA0i8"],["about","Nostr PHX presents Culture Shock : the first Value 4 Value Cultural Event in Downtown Phoenix. We will showcase the power of Nostr + Bitcoin / Lightning with a full day of education, conversation, food and goods which will be capped off with a live concert streamed globally for the world to boost \u0026 zap. \n\nWe strive to source local vendors, local artists, local partnerships. Please reach out to us if you are interested in participating in this historic event. "],["calendar","31924:5b0183ab6c3e322bf4d41c6b3aef98562a144847b7499543727c5539a114563e:1f238c94"]],"content":"Nostr PHX presents Culture Shock : the first Value 4 Value Cultural Event in Downtown Phoenix. We will showcase the power of Nostr + Bitcoin / Lightning with a full day of education, conversation, food and goods which will be capped off with a live concert streamed globally for the world to boost \u0026 zap. \n\nWe strive to source local vendors, local artists, local partnerships. Please reach out to us if you are interested in participating in this historic event. ","sig":"f676629d1414d96b464644de6babde0c96bd21ef9b41ba69ad886a1d13a942b855b715b22ccf38bc07fead18d3bdeee82d9e3825cf6f003fb5ff1766d95c70a0"}
// {"kind":1,"id":"3ff0d19493e661faa90ac06b5d8963ef1e48fe780368fe67ed0fd410df8a6dd5","pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","created_at":1710759386,"tags":[],"content":"Nostr was coopted by our the corporate overlords. It is now featured in https://www.iana.org/assignments/well-known-uris/well-known-uris.xhtml.","sig":"faaec167cca4de50b562b7702e8854e2023f0ccd5f36d1b95b6eac20d352206342d6987e9516d283068c768e94dbe8858e2990c3e05405e707fb6fb771ef92f9"}
}
func ExampleEncodeNprofile() {
app.Run([]string{"nak", "encode", "nprofile", "-r", "wss://example.com", "a6a67ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f8179822"})
func ExampleKeyPublic() {
app.Run(ctx, []string{"nak", "key", "public", "3ff0d19493e661faa90ac06b5d8963ef1e48fe780368fe67ed0fd410df8a6dd5", "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"})
// Output:
// nprofile1qqs2dfn7l8wthtz45p3ftn58pvrs9xlumvkuu2xet8egzkcklqtesgspz9mhxue69uhk27rpd4cxcefwvdhk6fl5jug
// 70f7120d065870513a6bddb61c8d400ad1e43449b1900ffdb5551e4c421375c8
// 718d756f60cf5179ef35b39dc6db3ff58f04c0734f81f6d4410f0b047ddf9029
}
func ExampleKeyDecrypt() {
app.Run(ctx, []string{"nak", "key", "decrypt", "ncryptsec1qgg2gx2a7hxpsse2zulrv7m8qwccvl3mh8e9k8vtz3wpyrwuuclaq73gz7ddt5kpa93qyfhfjakguuf8uhw90jn6mszh7kqeh9mxzlyw8hy75fluzx4h75frwmu2yngsq7hx7w32d0vdyxyns5g6rqft", "banana"})
// Output:
// 718d756f60cf5179ef35b39dc6db3ff58f04c0734f81f6d4410f0b047ddf9029
}
func ExampleReqIdFromRelay() {
app.Run(ctx, []string{"nak", "req", "-i", "20a6606ed548fe7107533cf3416ce1aa5e957c315c2a40249e12bd9873dca7da", "--limit", "1", "nos.lol"})
// Output:
// {"kind":1,"id":"20a6606ed548fe7107533cf3416ce1aa5e957c315c2a40249e12bd9873dca7da","pubkey":"dd664d5e4016433a8cd69f005ae1480804351789b59de5af06276de65633d319","created_at":1720972243,"tags":[["e","bdb2210fe6d9c4b141f08b5d9d1147cd8e1dc1d82f552a889ab171894249d21d","","root"],["e","c2e45f09e7d62ed12afe2b8b1bcf6be823b560a53ef06905365a78979a1b9ee3","","reply"],["p","036533caa872376946d4e4fdea4c1a0441eda38ca2d9d9417bb36006cbaabf58","","mention"]],"content":"Yeah, so bizarre, but I guess most people are meant to be serfs.","sig":"9ea7488415c250d0ac8fcb2219f211cb369dddf2a75c0f63d2db773c6dc1ef9dd9679b8941c0e7551744ea386afebad2024be8ce3ac418d4f47c95e7491af38e"}
}
func ExampleReqWithFlagsAfter1() {
app.Run(ctx, []string{"nak", "req", "nos.lol", "-i", "20a6606ed548fe7107533cf3416ce1aa5e957c315c2a40249e12bd9873dca7da", "--limit", "1"})
// Output:
// {"kind":1,"id":"20a6606ed548fe7107533cf3416ce1aa5e957c315c2a40249e12bd9873dca7da","pubkey":"dd664d5e4016433a8cd69f005ae1480804351789b59de5af06276de65633d319","created_at":1720972243,"tags":[["e","bdb2210fe6d9c4b141f08b5d9d1147cd8e1dc1d82f552a889ab171894249d21d","","root"],["e","c2e45f09e7d62ed12afe2b8b1bcf6be823b560a53ef06905365a78979a1b9ee3","","reply"],["p","036533caa872376946d4e4fdea4c1a0441eda38ca2d9d9417bb36006cbaabf58","","mention"]],"content":"Yeah, so bizarre, but I guess most people are meant to be serfs.","sig":"9ea7488415c250d0ac8fcb2219f211cb369dddf2a75c0f63d2db773c6dc1ef9dd9679b8941c0e7551744ea386afebad2024be8ce3ac418d4f47c95e7491af38e"}
}
func ExampleReqWithFlagsAfter2() {
app.Run(ctx, []string{"nak", "req", "-e", "893d4c10f1c230240812c6bdf9ad877eed1e29e87029d153820c24680bb183b1", "nostr.mom", "--author", "2a7dcf382bcc96a393ada5c975f500393b3f7be6e466bff220aa161ad6b15eb6", "--limit", "1", "-k", "7"})
// Output:
// {"kind":7,"id":"9b4868b068ea34ae51092807586c4541b3569d9efc23862aea48ef13de275857","pubkey":"2a7dcf382bcc96a393ada5c975f500393b3f7be6e466bff220aa161ad6b15eb6","created_at":1720987327,"tags":[["e","893d4c10f1c230240812c6bdf9ad877eed1e29e87029d153820c24680bb183b1"],["p","1e978baae414eee990dba992871549ad4a099b9d6f7e71c8059b254ea024dddc"],["k","1"]],"content":"❤️","sig":"7eddd112c642ecdb031330dadc021790642b3c10ecc64158ba3ae63edd798b26afb9b5a3bba72835ce171719a724de1472f65c9b3339b6bead0ce2846f93dfc9"}
}
func ExampleReqWithFlagsAfter3() {
app.Run(ctx, []string{"nak", "req", "--limit", "1", "pyramid.fiatjaf.com", "-a", "3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24", "-qp", "3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24", "-e", "9f3c1121c96edf17d84b9194f74d66d012b28c4e25b3ef190582c76b8546a188"})
// Output:
// {"kind":1,"id":"101572c80ebdc963dab8440f6307387a3023b6d90f7e495d6c5ee1ef77045a67","pubkey":"3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24","created_at":1720987305,"tags":[["e","ceacdc29fa7a0b51640b30d2424e188215460617db5ba5bb52d3fbf0094eebb3","","root"],["e","9f3c1121c96edf17d84b9194f74d66d012b28c4e25b3ef190582c76b8546a188","","reply"],["p","3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24"],["p","6b96c3eb36c6cd457d906bbaafe7b36cacfb8bcc4ab235be6eab3b71c6669251"]],"content":"Nope. I grew up playing in the woods. Never once saw a bear in the woods. If I did, I'd probably shiy my pants, then scream at it like I was a crazy person with my arms above my head to make me seem huge.","sig":"b098820b4a5635865cada9f9a5813be2bc6dd7180e16e590cf30e07916d8ed6ed98ab38b64f3bfba12d88d37335f229f7ef8c084bc48132e936c664a54d3e650"}
}
func ExampleNaturalTimestamps() {
app.Run(ctx, []string{"nak", "event", "-t", "plu=pla", "-e", "3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24", "--ts", "May 19 2018 03:37:19", "-c", "nn"})
// Output:
// {"kind":0,"id":"b10da0095f96aa2accd99fa3d93bf29a76f51d2594cf5a0a52f8e961aecd0b67","pubkey":"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798","created_at":1526711839,"tags":[["plu","pla"],["e","3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24"]],"content":"nn","sig":"988442c97064a041ba5e2bfbd64e84d3f819b2169e865511d9d53e74667949ff165325942acaa2ca233c8b529adedf12cf44088cf04081b56d098c5f4d52dd8f"}
}

142
fetch.go
View File

@@ -1,94 +1,124 @@
package main
import (
"context"
"fmt"
"github.com/fiatjaf/cli/v3"
"github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/nip05"
"github.com/nbd-wtf/go-nostr/nip19"
sdk "github.com/nbd-wtf/nostr-sdk"
"github.com/urfave/cli/v2"
"github.com/nbd-wtf/go-nostr/sdk/hints"
)
var fetch = &cli.Command{
Name: "fetch",
Usage: "fetches events related to the given nip19 code from the included relay hints",
Usage: "fetches events related to the given nip19 or nip05 code from the included relay hints or the author's outbox relays.",
Description: `example usage:
nak fetch nevent1qqsxrwm0hd3s3fddh4jc2574z3xzufq6qwuyz2rvv3n087zvym3dpaqprpmhxue69uhhqatzd35kxtnjv4kxz7tfdenju6t0xpnej4
echo npub1h8spmtw9m2huyv6v2j2qd5zv956z2zdugl6mgx02f2upffwpm3nqv0j4ps | nak fetch --relay wss://relay.nostr.band`,
Flags: []cli.Flag{
DisableSliceFlagSeparator: true,
Flags: append(reqFilterFlags,
&cli.StringSliceFlag{
Name: "relay",
Aliases: []string{"r"},
Usage: "also use these relays to fetch from",
},
},
ArgsUsage: "[nip19code]",
Action: func(c *cli.Context) error {
for code := range getStdinLinesOrFirstArgument(c) {
filter := nostr.Filter{}
),
ArgsUsage: "[nip05_or_nip19_code]",
Action: func(ctx context.Context, c *cli.Command) error {
defer func() {
sys.Pool.Relays.Range(func(_ string, relay *nostr.Relay) bool {
relay.Close()
return true
})
}()
prefix, value, err := nip19.Decode(code)
if err != nil {
lineProcessingError(c, "failed to decode: %s", err)
continue
for code := range getStdinLinesOrArguments(c.Args()) {
filter := nostr.Filter{}
var authorHint string
relays := c.StringSlice("relay")
if nip05.IsValidIdentifier(code) {
pp, err := nip05.QueryIdentifier(ctx, code)
if err != nil {
ctx = lineProcessingError(ctx, "failed to fetch nip05: %s", err)
continue
}
authorHint = pp.PublicKey
relays = append(relays, pp.Relays...)
filter.Authors = append(filter.Authors, pp.PublicKey)
} else {
prefix, value, err := nip19.Decode(code)
if err != nil {
ctx = lineProcessingError(ctx, "failed to decode: %s", err)
continue
}
if err := normalizeAndValidateRelayURLs(relays); err != nil {
return err
}
switch prefix {
case "nevent":
v := value.(nostr.EventPointer)
filter.IDs = append(filter.IDs, v.ID)
if v.Author != "" {
authorHint = v.Author
}
relays = append(relays, v.Relays...)
case "note":
filter.IDs = append(filter.IDs, value.(string))
case "naddr":
v := value.(nostr.EntityPointer)
filter.Kinds = []int{v.Kind}
filter.Tags = nostr.TagMap{"d": []string{v.Identifier}}
filter.Authors = append(filter.Authors, v.PublicKey)
authorHint = v.PublicKey
relays = append(relays, v.Relays...)
case "nprofile":
v := value.(nostr.ProfilePointer)
filter.Authors = append(filter.Authors, v.PublicKey)
authorHint = v.PublicKey
relays = append(relays, v.Relays...)
case "npub":
v := value.(string)
filter.Authors = append(filter.Authors, v)
authorHint = v
default:
return fmt.Errorf("unexpected prefix %s", prefix)
}
}
relays := c.StringSlice("relay")
if err := validateRelayURLs(relays); err != nil {
if authorHint != "" {
for _, url := range relays {
sys.Hints.Save(authorHint, nostr.NormalizeURL(url), hints.LastInHint, nostr.Now())
}
for _, url := range sys.FetchOutboxRelays(ctx, authorHint, 3) {
relays = append(relays, url)
}
}
if err := applyFlagsToFilter(c, &filter); err != nil {
return err
}
var authorHint string
switch prefix {
case "nevent":
v := value.(nostr.EventPointer)
filter.IDs = append(filter.IDs, v.ID)
if v.Author != "" {
authorHint = v.Author
}
relays = v.Relays
case "naddr":
v := value.(nostr.EntityPointer)
filter.Tags = nostr.TagMap{"d": []string{v.Identifier}}
filter.Kinds = append(filter.Kinds, v.Kind)
filter.Authors = append(filter.Authors, v.PublicKey)
authorHint = v.PublicKey
relays = v.Relays
case "nprofile":
v := value.(nostr.ProfilePointer)
filter.Authors = append(filter.Authors, v.PublicKey)
if len(filter.Authors) > 0 && len(filter.Kinds) == 0 {
filter.Kinds = append(filter.Kinds, 0)
authorHint = v.PublicKey
relays = v.Relays
case "npub":
v := value.(string)
filter.Authors = append(filter.Authors, v)
filter.Kinds = append(filter.Kinds, 0)
authorHint = v
}
pool := nostr.NewSimplePool(c.Context)
if authorHint != "" {
relayList := sdk.FetchRelaysForPubkey(c.Context, pool, authorHint,
"wss://purplepag.es", "wss://offchain.pub", "wss://public.relaying.io")
for _, relayListItem := range relayList {
if relayListItem.Outbox {
relays = append(relays, relayListItem.URL)
}
}
}
if len(relays) == 0 {
lineProcessingError(c, "no relay hints found")
ctx = lineProcessingError(ctx, "no relay hints found")
continue
}
for ie := range pool.SubManyEose(c.Context, relays, nostr.Filters{filter}) {
fmt.Println(ie.Event)
for ie := range sys.Pool.SubManyEose(ctx, relays, nostr.Filters{filter}) {
stdout(ie.Event)
}
}
exitIfLineProcessingError(c)
exitIfLineProcessingError(ctx)
return nil
},
}

95
flags.go Normal file
View File

@@ -0,0 +1,95 @@
package main
import (
"errors"
"fmt"
"strconv"
"time"
"github.com/fiatjaf/cli/v3"
"github.com/markusmobius/go-dateparser"
"github.com/nbd-wtf/go-nostr"
)
type NaturalTimeFlag = cli.FlagBase[nostr.Timestamp, struct{}, naturalTimeValue]
// wrap to satisfy golang's flag interface.
type naturalTimeValue struct {
timestamp *nostr.Timestamp
hasBeenSet bool
}
var _ cli.ValueCreator[nostr.Timestamp, struct{}] = naturalTimeValue{}
// Below functions are to satisfy the ValueCreator interface
func (t naturalTimeValue) Create(val nostr.Timestamp, p *nostr.Timestamp, c struct{}) cli.Value {
*p = val
return &naturalTimeValue{
timestamp: p,
}
}
func (t naturalTimeValue) ToString(b nostr.Timestamp) string {
ts := b.Time()
if ts.IsZero() {
return ""
}
return fmt.Sprintf("%v", ts)
}
// Timestamp constructor(for internal testing only)
func newTimestamp(timestamp nostr.Timestamp) *naturalTimeValue {
return &naturalTimeValue{timestamp: &timestamp}
}
// Below functions are to satisfy the flag.Value interface
// Parses the string value to timestamp
func (t *naturalTimeValue) Set(value string) error {
var ts time.Time
if n, err := strconv.ParseInt(value, 10, 64); err == nil {
// when the input is a raw number, treat it as an exact timestamp
ts = time.Unix(n, 0)
} else if errors.Is(err, strconv.ErrRange) {
// this means a huge number, so we should fail
return err
} else {
// otherwise try to parse it as a human date string in natural language
date, err := dateparser.Parse(&dateparser.Configuration{
DefaultTimezone: time.Local,
CurrentTime: time.Now(),
}, value)
ts = date.Time
if err != nil {
return err
}
}
if t.timestamp != nil {
*t.timestamp = nostr.Timestamp(ts.Unix())
}
t.hasBeenSet = true
return nil
}
// String returns a readable representation of this value (for usage defaults)
func (t *naturalTimeValue) String() string {
return fmt.Sprintf("%#v", t.timestamp)
}
// Value returns the timestamp value stored in the flag
func (t *naturalTimeValue) Value() *nostr.Timestamp {
return t.timestamp
}
// Get returns the flag structure
func (t *naturalTimeValue) Get() any {
return *t.timestamp
}
func getNaturalDate(cmd *cli.Command, name string) nostr.Timestamp {
return cmd.Value(name).(nostr.Timestamp)
}

85
go.mod
View File

@@ -1,40 +1,69 @@
module github.com/fiatjaf/nak
go 1.21
go 1.23.3
toolchain go1.21.0
toolchain go1.23.4
require (
github.com/bgentry/speakeasy v0.1.0
github.com/mailru/easyjson v0.7.7
github.com/nbd-wtf/go-nostr v0.25.3
github.com/nbd-wtf/nostr-sdk v0.0.2
github.com/urfave/cli/v2 v2.25.3
github.com/bep/debounce v1.2.1
github.com/btcsuite/btcd/btcec/v2 v2.3.4
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0
github.com/fatih/color v1.16.0
github.com/fiatjaf/cli/v3 v3.0.0-20240723181502-e7dd498b16ae
github.com/fiatjaf/eventstore v0.15.0
github.com/fiatjaf/khatru v0.15.0
github.com/json-iterator/go v1.1.12
github.com/mailru/easyjson v0.9.0
github.com/mark3labs/mcp-go v0.8.3
github.com/markusmobius/go-dateparser v1.2.3
github.com/nbd-wtf/go-nostr v0.49.3
)
require (
github.com/btcsuite/btcd/btcec/v2 v2.3.2 // indirect
github.com/btcsuite/btcd/btcutil v1.1.3 // indirect
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.2 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
github.com/decred/dcrd/crypto/blake256 v1.0.1 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect
github.com/dgraph-io/ristretto v0.1.1 // indirect
github.com/dustin/go-humanize v1.0.0 // indirect
github.com/fiatjaf/eventstore v0.1.0 // indirect
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.2.0 // indirect
github.com/golang/glog v1.0.0 // indirect
fiatjaf.com/lib v0.2.0 // indirect
github.com/andybalholm/brotli v1.0.5 // indirect
github.com/btcsuite/btcd v0.24.2 // indirect
github.com/btcsuite/btcd/btcutil v1.1.5 // indirect
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/chzyer/logex v1.1.10 // indirect
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 // indirect
github.com/coder/websocket v1.8.12 // indirect
github.com/decred/dcrd/crypto/blake256 v1.1.0 // indirect
github.com/dgraph-io/ristretto v1.0.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/elliotchance/pie/v2 v2.7.0 // indirect
github.com/elnosh/gonuts v0.3.1-0.20250123162555-7c0381a585e3 // indirect
github.com/fasthttp/websocket v1.5.7 // indirect
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/graph-gophers/dataloader/v7 v7.1.0 // indirect
github.com/hablullah/go-hijri v1.0.2 // indirect
github.com/hablullah/go-juliandays v1.0.0 // indirect
github.com/jalaali/go-jalaali v0.0.0-20210801064154-80525e88d958 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/klauspost/compress v1.17.11 // indirect
github.com/magefile/mage v1.14.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/puzpuzpuz/xsync/v2 v2.5.1 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/tidwall/gjson v1.14.4 // indirect
github.com/puzpuzpuz/xsync/v3 v3.4.0 // indirect
github.com/rs/cors v1.11.1 // indirect
github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee // indirect
github.com/tetratelabs/wazero v1.8.0 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
golang.org/x/exp v0.0.0-20230425010034-47ecfdc1ba53 // indirect
golang.org/x/sys v0.8.0 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.51.0 // indirect
github.com/wasilibs/go-re2 v1.3.0 // indirect
github.com/x448/float16 v0.8.4 // indirect
golang.org/x/crypto v0.32.0 // indirect
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 // indirect
golang.org/x/net v0.34.0 // indirect
golang.org/x/sys v0.29.0 // indirect
golang.org/x/text v0.21.0 // indirect
)

186
go.sum
View File

@@ -1,21 +1,27 @@
fiatjaf.com/lib v0.2.0 h1:TgIJESbbND6GjOgGHxF5jsO6EMjuAxIzZHPo5DXYexs=
fiatjaf.com/lib v0.2.0/go.mod h1:Ycqq3+mJ9jAWu7XjbQI1cVr+OFgnHn79dQR5oTII47g=
github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII=
github.com/bgentry/speakeasy v0.1.0 h1:ByYyxL9InA1OWqxJqqp2A5pYHUrCiAL6K3J+LKSsQkY=
github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs=
github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ=
github.com/btcsuite/btcd v0.22.0-beta.0.20220111032746-97732e52810c/go.mod h1:tjmYdS6MLJ5/s0Fj4DbLgSbDHbEqLJrtnHecBFkdz5M=
github.com/btcsuite/btcd v0.23.0/go.mod h1:0QJIIN1wwIXF/3G/m87gIwGniDMDQqjVn4SZgnFpsYY=
github.com/btcsuite/btcd v0.23.5-0.20231215221805-96c9fd8078fd/go.mod h1:nm3Bko6zh6bWP60UxwoT5LzdGJsQJaPo6HjduXq9p6A=
github.com/btcsuite/btcd v0.24.2 h1:aLmxPguqxza+4ag8R1I2nnJjSu2iFn/kqtHTIImswcY=
github.com/btcsuite/btcd v0.24.2/go.mod h1:5C8ChTkl5ejr3WHj8tkQSCmydiMEPB0ZhQhehpq7Dgg=
github.com/btcsuite/btcd/btcec/v2 v2.1.0/go.mod h1:2VzYrv4Gm4apmbVVsSq5bqf1Ec8v56E48Vt0Y/umPgA=
github.com/btcsuite/btcd/btcec/v2 v2.1.3/go.mod h1:ctjw4H1kknNJmRN4iP1R7bTQ+v3GJkZBd6mui8ZsAZE=
github.com/btcsuite/btcd/btcec/v2 v2.3.2 h1:5n0X6hX0Zk+6omWcihdYvdAlGf2DfasC0GMf7DClJ3U=
github.com/btcsuite/btcd/btcec/v2 v2.3.2/go.mod h1:zYzJ8etWJQIv1Ogk7OzpWjowwOdXY1W/17j2MW85J04=
github.com/btcsuite/btcd/btcec/v2 v2.3.4 h1:3EJjcN70HCu/mwqlUsGK8GcNVyLVxFDlWurTXGPFfiQ=
github.com/btcsuite/btcd/btcec/v2 v2.3.4/go.mod h1:zYzJ8etWJQIv1Ogk7OzpWjowwOdXY1W/17j2MW85J04=
github.com/btcsuite/btcd/btcutil v1.0.0/go.mod h1:Uoxwv0pqYWhD//tfTiipkxNfdhG9UrLwaeswfjfdF0A=
github.com/btcsuite/btcd/btcutil v1.1.0/go.mod h1:5OapHB7A2hBBWLm48mmw4MOHNJCcUBTwmWH/0Jn8VHE=
github.com/btcsuite/btcd/btcutil v1.1.3 h1:xfbtw8lwpp0G6NwSHb+UE67ryTFHJAiNuipusjXSohQ=
github.com/btcsuite/btcd/btcutil v1.1.3/go.mod h1:UR7dsSJzJUfMmFiiLlIrMq1lS9jh9EdCV7FStZSnpi0=
github.com/btcsuite/btcd/btcutil v1.1.5 h1:+wER79R5670vs/ZusMTF1yTcRYE5GUsFbdjdisflzM8=
github.com/btcsuite/btcd/btcutil v1.1.5/go.mod h1:PSZZ4UitpLBWzxGd5VGOrLnmOjtPP/a6HaFo12zMs00=
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.2 h1:KdUfX2zKommPRa+PD0sWZUyXe9w277ABlgELO7H04IM=
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.2/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 h1:59Kx4K6lzOW5w6nFlA0v5+lk/6sjybR934QNHSJZPTQ=
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA=
github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg=
github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd/go.mod h1:HHNXQzUsZCxOoE+CPiyCTO6x34Zs86zZUiwtpXoGdtg=
@@ -25,41 +31,51 @@ github.com/btcsuite/snappy-go v0.0.0-20151229074030-0bdef8d06723/go.mod h1:8woku
github.com/btcsuite/snappy-go v1.0.0/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc=
github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY=
github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w=
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc=
github.com/decred/dcrd/crypto/blake256 v1.0.1 h1:7PltbUIQB7u/FfZ39+DGa/ShuMyJ5ilcvdfma9wOH6Y=
github.com/decred/dcrd/crypto/blake256 v1.0.1/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo=
github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8=
github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 h1:rpfIENRNNilwHwZeG5+P150SMrnNEcHYvcCuK6dPZSg=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0=
github.com/decred/dcrd/lru v1.0.0/go.mod h1:mxKOwFd7lFjN2GZYsiz/ecgqR6kkYAl+0pz0tEMk218=
github.com/dgraph-io/ristretto v0.1.1 h1:6CWw5tJNgpegArSHpNHJKldNeq03FQCwYvfMVWajOK8=
github.com/dgraph-io/ristretto v0.1.1/go.mod h1:S1GPSBCYCIhmVNfcth17y2zZtQT6wzkzgwUve0VDWWA=
github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2 h1:tdlZCpZ/P9DhczCTSixgIKmwPv6+wP5DGjqLYw5SUiA=
github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/fiatjaf/eventstore v0.1.0 h1:/g7VTw6dsXmjICD3rBuHNIvAammHJ5unrKJ71Dz+VTs=
github.com/fiatjaf/eventstore v0.1.0/go.mod h1:juMei5HL3HJi6t7vZjj7VdEItDPu31+GLROepdUK4tw=
github.com/dgraph-io/ristretto v1.0.0 h1:SYG07bONKMlFDUYu5pEu3DGAh8c2OFNzKm6G9J4Si84=
github.com/dgraph-io/ristretto v1.0.0/go.mod h1:jTi2FiYEhQ1NsMmA7DeBykizjOuY88NhKBkepyu1jPc=
github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 h1:fAjc9m62+UWV/WAFKLNi6ZS0675eEUC9y3AlwSbQu1Y=
github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/elliotchance/pie/v2 v2.7.0 h1:FqoIKg4uj0G/CrLGuMS9ejnFKa92lxE1dEgBD3pShXg=
github.com/elliotchance/pie/v2 v2.7.0/go.mod h1:18t0dgGFH006g4eVdDtWfgFZPQEgl10IoEO8YWEq3Og=
github.com/elnosh/gonuts v0.3.1-0.20250123162555-7c0381a585e3 h1:k7evIqJ2BtFn191DgY/b03N2bMYA/iQwzr4f/uHYn20=
github.com/elnosh/gonuts v0.3.1-0.20250123162555-7c0381a585e3/go.mod h1:vgZomh4YQk7R3w4ltZc0sHwCmndfHkuX6V4sga/8oNs=
github.com/fasthttp/websocket v1.5.7 h1:0a6o2OfeATvtGgoMKleURhLT6JqWPg7fYfWnH4KHau4=
github.com/fasthttp/websocket v1.5.7/go.mod h1:bC4fxSono9czeXHQUVKxsC0sNjbm7lPJR04GDFqClfU=
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
github.com/fiatjaf/cli/v3 v3.0.0-20240723181502-e7dd498b16ae h1:0B/1dU3YECIbPoBIRTQ4c0scZCNz9TVHtQpiODGrTTo=
github.com/fiatjaf/cli/v3 v3.0.0-20240723181502-e7dd498b16ae/go.mod h1:aAWPO4bixZZxPtOnH6K3q4GbQ0jftUNDW9Oa861IRew=
github.com/fiatjaf/eventstore v0.15.0 h1:5UXe0+vIb30/cYcOWipks8nR3g+X8W224TFy5yPzivk=
github.com/fiatjaf/eventstore v0.15.0/go.mod h1:KAsld5BhkmSck48aF11Txu8X+OGNmoabw4TlYVWqInc=
github.com/fiatjaf/khatru v0.15.0 h1:0aLWiTrdzoKD4WmW35GWL/Jsn4dACCUw325JKZg/AmI=
github.com/fiatjaf/khatru v0.15.0/go.mod h1:GBQJXZpitDatXF9RookRXcWB5zCJclCE4ufDK3jk80g=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.2.0 h1:u0p9s3xLYpZCA1z5JgCkMeB34CKCMMQbM+G8Ii7YD0I=
github.com/gobwas/ws v1.2.0/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/glog v1.0.0 h1:nfP3RFugxnNRyKgeWd4oI1nYvXpxrx8ck8ZrcizshdQ=
github.com/golang/glog v1.0.0/go.mod h1:EWib/APOK0SL3dFbYqvxE3UYd8E6s1ouQ7iEp/0LWV4=
github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
@@ -71,19 +87,49 @@ github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEW
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/graph-gophers/dataloader/v7 v7.1.0 h1:Wn8HGF/q7MNXcvfaBnLEPEFJttVHR8zuEqP1obys/oc=
github.com/graph-gophers/dataloader/v7 v7.1.0/go.mod h1:1bKE0Dm6OUcTB/OAuYVOZctgIz7Q3d0XrYtlIzTgg6Q=
github.com/hablullah/go-hijri v1.0.2 h1:drT/MZpSZJQXo7jftf5fthArShcaMtsal0Zf/dnmp6k=
github.com/hablullah/go-hijri v1.0.2/go.mod h1:OS5qyYLDjORXzK4O1adFw9Q5WfhOcMdAKglDkcTxgWQ=
github.com/hablullah/go-juliandays v1.0.0 h1:A8YM7wIj16SzlKT0SRJc9CD29iiaUzpBLzh5hr0/5p0=
github.com/hablullah/go-juliandays v1.0.0/go.mod h1:0JOYq4oFOuDja+oospuc61YoX+uNEn7Z6uHYTbBzdGc=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/jalaali/go-jalaali v0.0.0-20210801064154-80525e88d958 h1:qxLoi6CAcXVzjfvu+KXIXJOAsQB62LXjsfbOaErsVzE=
github.com/jalaali/go-jalaali v0.0.0-20210801064154-80525e88d958/go.mod h1:Wqfu7mjUHj9WDzSSPI5KfBclTTEnLveRUFr/ujWnTgE=
github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/nbd-wtf/go-nostr v0.25.3 h1:RPPh4cOosw0OZi5KG627pZ3GlKxiKsjARluzen/mB9g=
github.com/nbd-wtf/go-nostr v0.25.3/go.mod h1:bkffJI+x914sPQWum9ZRUn66D7NpDnAoWo1yICvj3/0=
github.com/nbd-wtf/nostr-sdk v0.0.2 h1:mZIeti+DOF0D1179q+NLL/h0LVMMOPRQAYpOuUrn5Zk=
github.com/nbd-wtf/nostr-sdk v0.0.2/go.mod h1:KQZOtzcrXBlVhpZYG1tw83ADIONNMMPjUU3ZAH5U2RY=
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
github.com/magefile/mage v1.14.0 h1:6QDX3g6z1YvJ4olPhT1wksUcSa/V0a1B+pJb73fBjyo=
github.com/magefile/mage v1.14.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mark3labs/mcp-go v0.8.3 h1:IzlyN8BaP4YwUMUDqxOGJhGdZXEDQiAPX43dNPgnzrg=
github.com/mark3labs/mcp-go v0.8.3/go.mod h1:cjMlBU0cv/cj9kjlgmRhoJ5JREdS7YX83xeIG9Ko/jE=
github.com/markusmobius/go-dateparser v1.2.3 h1:TvrsIvr5uk+3v6poDjaicnAFJ5IgtFHgLiuMY2Eb7Nw=
github.com/markusmobius/go-dateparser v1.2.3/go.mod h1:cMwQRrBUQlK1UI5TIFHEcvpsMbkWrQLXuaPNMFzuYLk=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/nbd-wtf/go-nostr v0.49.3 h1:7tsEdMZOtJ764JuMLffkbhVUi4yyf688dbqArLvItPs=
github.com/nbd-wtf/go-nostr v0.49.3/go.mod h1:M50QnhkraC5Ol93v3jqxSMm1aGxUQm5mlmkYw5DJzh8=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
@@ -97,38 +143,51 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/puzpuzpuz/xsync/v2 v2.5.1 h1:mVGYAvzDSu52+zaGyNjC+24Xw2bQi3kTr4QJ6N9pIIU=
github.com/puzpuzpuz/xsync/v2 v2.5.1/go.mod h1:gD2H2krq/w52MfPLE+Uy64TzJDVY7lP2znR9qmR35kU=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/puzpuzpuz/xsync/v3 v3.4.0 h1:DuVBAdXuGFHv8adVXjWWZ63pJq+NRXOWVXlKDBZ+mJ4=
github.com/puzpuzpuz/xsync/v3 v3.4.0/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA=
github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA=
github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee h1:8Iv5m6xEo1NR1AvpV+7XmhI4r39LGNzwUL4YpMuL5vk=
github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee/go.mod h1:qwtSXrKuJh/zsFQ12yEE89xfCrGKK63Rr7ctU/uCo4g=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc=
github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM=
github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tetratelabs/wazero v1.8.0 h1:iEKu0d4c2Pd+QSRieYbnQC9yiFlMS9D+Jr0LsRmcF4g=
github.com/tetratelabs/wazero v1.8.0/go.mod h1:yAI0XTsMBhREkM/YDAK/zNou3GoiAce1P6+rp/wQhjs=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/urfave/cli/v2 v2.25.3 h1:VJkt6wvEBOoSjPFQvOkv6iWIrsJyCrKGtCtxXWwmGeY=
github.com/urfave/cli/v2 v2.25.3/go.mod h1:GHupkWPMM0M/sj1a2b4wUrWBPzazNrIjouW6fmdJLxc=
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA=
github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g=
github.com/wasilibs/go-re2 v1.3.0 h1:LFhBNzoStM3wMie6rN2slD1cuYH2CGiHpvNL3UtcsMw=
github.com/wasilibs/go-re2 v1.3.0/go.mod h1:AafrCXVvGRJJOImMajgJ2M7rVmWyisVK7sFshbxnVrg=
github.com/wasilibs/nottinygc v0.4.0 h1:h1TJMihMC4neN6Zq+WKpLxgd9xCFMw7O9ETLwY2exJQ=
github.com/wasilibs/nottinygc v0.4.0/go.mod h1:oDcIotskuYNMpqMF23l7Z8uzD4TC0WXHK8jetlB3HIo=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/exp v0.0.0-20230425010034-47ecfdc1ba53 h1:5llv2sWeaMSnA3w2kS57ouQQ4pudlXrR0dCgw51QK9o=
golang.org/x/exp v0.0.0-20230425010034-47ecfdc1ba53/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w=
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 h1:yqrTHse8TCMW1M1ZCP+VAR/l0kKxwaAIqN/il7x4voA=
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU=
golang.org/x/net v0.0.0-20180719180050-a680a1efc54d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -139,13 +198,15 @@ golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20221010170243-090e33056c14/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -159,7 +220,6 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -3,71 +3,132 @@ package main
import (
"bufio"
"context"
"encoding/hex"
"fmt"
"iter"
"math/rand"
"net/http"
"net/textproto"
"net/url"
"os"
"slices"
"strings"
"time"
"github.com/urfave/cli/v2"
"github.com/fatih/color"
"github.com/fiatjaf/cli/v3"
jsoniter "github.com/json-iterator/go"
"github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/sdk"
)
var sys *sdk.System
var (
hintsFilePath string
hintsFileExists bool
)
var json = jsoniter.ConfigFastest
const (
LINE_PROCESSING_ERROR = iota
)
var (
log = func(msg string, args ...any) { fmt.Fprintf(color.Error, msg, args...) }
logverbose = func(msg string, args ...any) {} // by default do nothing
stdout = fmt.Println
)
func isPiped() bool {
stat, _ := os.Stdin.Stat()
return stat.Mode()&os.ModeCharDevice == 0
}
func getStdinLinesOrBlank() chan string {
multi := make(chan string)
if hasStdinLines := writeStdinLinesOrNothing(multi); !hasStdinLines {
single := make(chan string, 1)
single <- ""
close(single)
return single
} else {
return multi
func getJsonsOrBlank() iter.Seq[string] {
var curr strings.Builder
return func(yield func(string) bool) {
hasStdin := writeStdinLinesOrNothing(func(stdinLine string) bool {
// we're look for an event, but it may be in multiple lines, so if json parsing fails
// we'll try the next line until we're successful
curr.WriteString(stdinLine)
stdinEvent := curr.String()
var dummy any
if err := json.Unmarshal([]byte(stdinEvent), &dummy); err != nil {
return true
}
if !yield(stdinEvent) {
return false
}
curr.Reset()
return true
})
if !hasStdin {
yield("{}")
}
}
}
func getStdinLinesOrFirstArgument(c *cli.Context) chan string {
func getStdinLinesOrBlank() iter.Seq[string] {
return func(yield func(string) bool) {
hasStdin := writeStdinLinesOrNothing(func(stdinLine string) bool {
if !yield(stdinLine) {
return false
}
return true
})
if !hasStdin {
yield("")
}
}
}
func getStdinLinesOrArguments(args cli.Args) iter.Seq[string] {
return getStdinLinesOrArgumentsFromSlice(args.Slice())
}
func getStdinLinesOrArgumentsFromSlice(args []string) iter.Seq[string] {
// try the first argument
target := c.Args().First()
if target != "" {
single := make(chan string, 1)
single <- target
close(single)
return single
if len(args) > 0 {
return slices.Values(args)
}
// try the stdin
multi := make(chan string)
writeStdinLinesOrNothing(multi)
return multi
return func(yield func(string) bool) {
writeStdinLinesOrNothing(yield)
}
}
func writeStdinLinesOrNothing(ch chan string) (hasStdinLines bool) {
func writeStdinLinesOrNothing(yield func(string) bool) (hasStdinLines bool) {
if isPiped() {
// piped
go func() {
scanner := bufio.NewScanner(os.Stdin)
for scanner.Scan() {
ch <- strings.TrimSpace(scanner.Text())
scanner := bufio.NewScanner(os.Stdin)
scanner.Buffer(make([]byte, 16*1024*1024), 256*1024*1024)
hasEmittedAtLeastOne := false
for scanner.Scan() {
if !yield(strings.TrimSpace(scanner.Text())) {
return
}
close(ch)
}()
return true
hasEmittedAtLeastOne = true
}
return hasEmittedAtLeastOne
} else {
// not piped
return false
}
}
func validateRelayURLs(wsurls []string) error {
for _, wsurl := range wsurls {
func normalizeAndValidateRelayURLs(wsurls []string) error {
for i, wsurl := range wsurls {
wsurl = nostr.NormalizeURL(wsurl)
wsurls[i] = wsurl
u, err := url.Parse(wsurl)
if err != nil {
return fmt.Errorf("invalid relay url '%s': %s", wsurl, err)
@@ -85,27 +146,104 @@ func validateRelayURLs(wsurls []string) error {
return nil
}
func validate32BytesHex(target string) error {
if _, err := hex.DecodeString(target); err != nil {
return fmt.Errorf("target '%s' is not valid hex: %s", target, err)
}
if len(target) != 64 {
return fmt.Errorf("expected '%s' to be 64 characters (32 bytes), got %d", target, len(target))
}
if strings.ToLower(target) != target {
return fmt.Errorf("expected target to be all lowercase hex. try again with '%s'", strings.ToLower(target))
}
func connectToAllRelays(
ctx context.Context,
relayUrls []string,
forcePreAuth bool,
opts ...nostr.PoolOption,
) []*nostr.Relay {
sys.Pool = nostr.NewSimplePool(context.Background(),
append(opts,
nostr.WithEventMiddleware(sys.TrackEventHints),
nostr.WithPenaltyBox(),
nostr.WithRelayOptions(
nostr.WithRequestHeader(http.Header{textproto.CanonicalMIMEHeaderKey("user-agent"): {"nak/s"}}),
),
)...,
)
return nil
relays := make([]*nostr.Relay, 0, len(relayUrls))
relayLoop:
for _, url := range relayUrls {
log("connecting to %s... ", url)
if relay, err := sys.Pool.EnsureRelay(url); err == nil {
if forcePreAuth {
log("waiting for auth challenge... ")
signer := opts[0].(nostr.WithAuthHandler)
time.Sleep(time.Millisecond * 200)
challengeWaitLoop:
for {
// beginhack
// here starts the biggest and ugliest hack of this codebase
if err := relay.Auth(ctx, func(authEvent *nostr.Event) error {
challengeTag := authEvent.Tags.GetFirst([]string{"challenge", ""})
if (*challengeTag)[1] == "" {
return fmt.Errorf("auth not received yet *****")
}
return signer(ctx, nostr.RelayEvent{Event: authEvent, Relay: relay})
}); err == nil {
// auth succeeded
break challengeWaitLoop
} else {
// auth failed
if strings.HasSuffix(err.Error(), "auth not received yet *****") {
// it failed because we didn't receive the challenge yet, so keep waiting
time.Sleep(time.Second)
continue challengeWaitLoop
} else {
// it failed for some other reason, so skip this relay
log(err.Error() + "\n")
continue relayLoop
}
}
// endhack
}
}
relays = append(relays, relay)
log("ok.\n")
} else {
log(err.Error() + "\n")
}
}
return relays
}
func lineProcessingError(c *cli.Context, msg string, args ...any) {
c.Context = context.WithValue(c.Context, LINE_PROCESSING_ERROR, true)
fmt.Fprintf(os.Stderr, msg+"\n", args...)
func lineProcessingError(ctx context.Context, msg string, args ...any) context.Context {
log(msg+"\n", args...)
return context.WithValue(ctx, LINE_PROCESSING_ERROR, true)
}
func exitIfLineProcessingError(c *cli.Context) {
if val := c.Context.Value(LINE_PROCESSING_ERROR); val != nil && val.(bool) {
func exitIfLineProcessingError(ctx context.Context) {
if val := ctx.Value(LINE_PROCESSING_ERROR); val != nil && val.(bool) {
os.Exit(123)
}
}
const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
func randString(n int) string {
b := make([]byte, n)
for i := range b {
b[i] = letterBytes[rand.Intn(len(letterBytes))]
}
return string(b)
}
func leftPadKey(k string) string {
return strings.Repeat("0", 64-len(k)) + k
}
var colors = struct {
reset func(...any) (int, error)
italic func(...any) string
italicf func(string, ...any) string
bold func(...any) string
boldf func(string, ...any) string
}{
color.New(color.Reset).Print,
color.New(color.Italic).Sprint,
color.New(color.Italic).Sprintf,
color.New(color.Bold).Sprint,
color.New(color.Bold).Sprintf,
}

161
helpers_key.go Normal file
View File

@@ -0,0 +1,161 @@
package main
import (
"context"
"encoding/hex"
"fmt"
"os"
"strings"
"github.com/chzyer/readline"
"github.com/fatih/color"
"github.com/fiatjaf/cli/v3"
"github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/keyer"
"github.com/nbd-wtf/go-nostr/nip19"
"github.com/nbd-wtf/go-nostr/nip46"
"github.com/nbd-wtf/go-nostr/nip49"
)
var defaultKeyFlags = []cli.Flag{
&cli.StringFlag{
Name: "sec",
Usage: "secret key to sign the event, as nsec, ncryptsec or hex, or a bunker URL, it is more secure to use the environment variable NOSTR_SECRET_KEY than this flag",
DefaultText: "the key '1'",
Aliases: []string{"connect"},
Category: CATEGORY_SIGNER,
},
&cli.BoolFlag{
Name: "prompt-sec",
Usage: "prompt the user to paste a hex or nsec with which to sign the event",
Category: CATEGORY_SIGNER,
},
&cli.StringFlag{
Name: "connect-as",
Usage: "private key to use when communicating with nip46 bunkers",
DefaultText: "a random key",
Category: CATEGORY_SIGNER,
Sources: cli.EnvVars("NOSTR_CLIENT_KEY"),
},
}
func gatherKeyerFromArguments(ctx context.Context, c *cli.Command) (nostr.Keyer, string, error) {
key, bunker, err := gatherSecretKeyOrBunkerFromArguments(ctx, c)
if err != nil {
return nil, "", err
}
var kr nostr.Keyer
if bunker != nil {
kr = keyer.NewBunkerSignerFromBunkerClient(bunker)
} else {
kr, err = keyer.NewPlainKeySigner(key)
}
return kr, key, err
}
func gatherSecretKeyOrBunkerFromArguments(ctx context.Context, c *cli.Command) (string, *nip46.BunkerClient, error) {
var err error
sec := c.String("sec")
if strings.HasPrefix(sec, "bunker://") {
// it's a bunker
bunkerURL := sec
clientKey := c.String("connect-as")
if clientKey != "" {
clientKey = strings.Repeat("0", 64-len(clientKey)) + clientKey
} else {
clientKey = nostr.GeneratePrivateKey()
}
bunker, err := nip46.ConnectBunker(ctx, clientKey, bunkerURL, nil, func(s string) {
log(color.CyanString("[nip46]: open the following URL: %s"), s)
})
return "", bunker, err
}
// take private from flags, environment variable or default to 1
if sec == "" {
if key, ok := os.LookupEnv("NOSTR_SECRET_KEY"); ok {
sec = key
} else {
sec = "0000000000000000000000000000000000000000000000000000000000000001"
}
}
if c.Bool("prompt-sec") {
if isPiped() {
return "", nil, fmt.Errorf("can't prompt for a secret key when processing data from a pipe, try again without --prompt-sec")
}
sec, err = askPassword("type your secret key as ncryptsec, nsec or hex: ", nil)
if err != nil {
return "", nil, fmt.Errorf("failed to get secret key: %w", err)
}
}
if strings.HasPrefix(sec, "ncryptsec1") {
sec, err = promptDecrypt(sec)
if err != nil {
return "", nil, fmt.Errorf("failed to decrypt: %w", err)
}
} else if bsec, err := hex.DecodeString(leftPadKey(sec)); err == nil {
sec = hex.EncodeToString(bsec)
} else if prefix, hexvalue, err := nip19.Decode(sec); err != nil {
return "", nil, fmt.Errorf("invalid nsec: %w", err)
} else if prefix == "nsec" {
sec = hexvalue.(string)
}
if ok := nostr.IsValid32ByteHex(sec); !ok {
return "", nil, fmt.Errorf("invalid secret key")
}
return sec, nil, nil
}
func promptDecrypt(ncryptsec string) (string, error) {
for i := 1; i < 4; i++ {
var attemptStr string
if i > 1 {
attemptStr = fmt.Sprintf(" [%d/3]", i)
}
password, err := askPassword("type the password to decrypt your secret key"+attemptStr+": ", nil)
if err != nil {
return "", err
}
sec, err := nip49.Decrypt(ncryptsec, password)
if err != nil {
continue
}
return sec, nil
}
return "", fmt.Errorf("couldn't decrypt private key")
}
func askPassword(msg string, shouldAskAgain func(answer string) bool) (string, error) {
config := &readline.Config{
Stdout: color.Error,
Prompt: color.YellowString(msg),
InterruptPrompt: "^C",
DisableAutoSaveHistory: true,
EnableMask: true,
MaskRune: '*',
}
rl, err := readline.NewEx(config)
if err != nil {
return "", err
}
for {
answer, err := rl.Readline()
if err != nil {
return "", err
}
answer = strings.TrimSpace(answer)
if shouldAskAgain != nil && shouldAskAgain(answer) {
continue
}
return answer, err
}
}

292
key.go Normal file
View File

@@ -0,0 +1,292 @@
package main
import (
"context"
"encoding/hex"
"fmt"
"strings"
"github.com/btcsuite/btcd/btcec/v2"
"github.com/btcsuite/btcd/btcec/v2/schnorr/musig2"
"github.com/decred/dcrd/dcrec/secp256k1/v4"
"github.com/fiatjaf/cli/v3"
"github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/nip19"
"github.com/nbd-wtf/go-nostr/nip49"
)
var key = &cli.Command{
Name: "key",
Usage: "operations on secret keys: generate, derive, encrypt, decrypt",
Description: ``,
DisableSliceFlagSeparator: true,
Commands: []*cli.Command{
generate,
public,
encryptKey,
decryptKey,
combine,
},
}
var generate = &cli.Command{
Name: "generate",
Usage: "generates a secret key",
Description: ``,
DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error {
sec := nostr.GeneratePrivateKey()
stdout(sec)
return nil
},
}
var public = &cli.Command{
Name: "public",
Usage: "computes a public key from a secret key",
Description: ``,
ArgsUsage: "[secret]",
DisableSliceFlagSeparator: true,
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "with-parity",
Usage: "output 33 bytes instead of 32, the first one being either '02' or '03', a prefix indicating whether this pubkey is even or odd.",
},
},
Action: func(ctx context.Context, c *cli.Command) error {
for sec := range getSecretKeysFromStdinLinesOrSlice(ctx, c, c.Args().Slice()) {
b, _ := hex.DecodeString(sec)
_, pk := btcec.PrivKeyFromBytes(b)
if c.Bool("with-parity") {
stdout(hex.EncodeToString(pk.SerializeCompressed()))
} else {
stdout(hex.EncodeToString(pk.SerializeCompressed()[1:]))
}
}
return nil
},
}
var encryptKey = &cli.Command{
Name: "encrypt",
Usage: "encrypts a secret key and prints an ncryptsec code",
Description: `uses the nip49 standard.`,
ArgsUsage: "<secret> <password>",
DisableSliceFlagSeparator: true,
Flags: []cli.Flag{
&cli.IntFlag{
Name: "logn",
Usage: "the bigger the number the harder it will be to bruteforce the password",
Value: 16,
DefaultText: "16",
},
},
Action: func(ctx context.Context, c *cli.Command) error {
keys := make([]string, 0, 1)
var password string
switch c.Args().Len() {
case 1:
password = c.Args().Get(0)
case 2:
keys = append(keys, c.Args().Get(0))
password = c.Args().Get(1)
}
if password == "" {
return fmt.Errorf("no password given")
}
for sec := range getSecretKeysFromStdinLinesOrSlice(ctx, c, keys) {
ncryptsec, err := nip49.Encrypt(sec, password, uint8(c.Int("logn")), 0x02)
if err != nil {
ctx = lineProcessingError(ctx, "failed to encrypt: %s", err)
continue
}
stdout(ncryptsec)
}
return nil
},
}
var decryptKey = &cli.Command{
Name: "decrypt",
Usage: "takes an ncrypsec and a password and decrypts it into an nsec",
Description: `uses the nip49 standard.`,
ArgsUsage: "<ncryptsec-code> <password>",
DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error {
var ncryptsec string
var password string
switch c.Args().Len() {
case 2:
ncryptsec = c.Args().Get(0)
password = c.Args().Get(1)
if password == "" {
return fmt.Errorf("no password given")
}
sec, err := nip49.Decrypt(ncryptsec, password)
if err != nil {
return fmt.Errorf("failed to decrypt: %s", err)
}
stdout(sec)
return nil
case 1:
if arg := c.Args().Get(0); strings.HasPrefix(arg, "ncryptsec1") {
ncryptsec = arg
if res, err := promptDecrypt(ncryptsec); err != nil {
return err
} else {
stdout(res)
return nil
}
} else {
password = c.Args().Get(0)
for ncryptsec := range getStdinLinesOrArgumentsFromSlice([]string{ncryptsec}) {
sec, err := nip49.Decrypt(ncryptsec, password)
if err != nil {
ctx = lineProcessingError(ctx, "failed to decrypt: %s", err)
continue
}
stdout(sec)
}
return nil
}
default:
return fmt.Errorf("invalid number of arguments")
}
},
}
var combine = &cli.Command{
Name: "combine",
Usage: "combines two or more pubkeys using musig2",
Description: `The public keys must have 33 bytes (66 characters hex), with the 02 or 03 prefix. It is common in Nostr to drop that first byte, so you'll have to derive the public keys again from the private keys in order to get it back.
However, if the intent is to check if two existing Nostr pubkeys match a given combined pubkey, then it might be sufficient to calculate the combined key for all the possible combinations of pubkeys in the input.`,
ArgsUsage: "[pubkey...]",
DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error {
type Combination struct {
Variants []string `json:"input_variants"`
Output struct {
XOnly string `json:"x_only"`
Variant string `json:"variant"`
} `json:"combined_key"`
}
type Result struct {
Keys []string `json:"keys"`
Combinations []Combination `json:"combinations"`
}
result := Result{}
result.Keys = c.Args().Slice()
keyGroups := make([][]*btcec.PublicKey, 0, len(result.Keys))
for i, keyhex := range result.Keys {
keyb, err := hex.DecodeString(keyhex)
if err != nil {
return fmt.Errorf("error parsing key %s: %w", keyhex, err)
}
if len(keyb) == 32 /* we'll use both the 02 and the 03 prefix versions */ {
group := make([]*btcec.PublicKey, 2)
for i, prefix := range []byte{0x02, 0x03} {
pubk, err := btcec.ParsePubKey(append([]byte{prefix}, keyb...))
if err != nil {
log("error parsing key %s: %s", keyhex, err)
continue
}
group[i] = pubk
}
keyGroups = append(keyGroups, group)
} else /* assume it's 33 */ {
pubk, err := btcec.ParsePubKey(keyb)
if err != nil {
return fmt.Errorf("error parsing key %s: %w", keyhex, err)
}
keyGroups = append(keyGroups, []*btcec.PublicKey{pubk})
// remove the leading byte from the output just so it is all uniform
result.Keys[i] = result.Keys[i][2:]
}
}
result.Combinations = make([]Combination, 0, 16)
var fn func(prepend int, curr []int)
fn = func(prepend int, curr []int) {
curr = append([]int{prepend}, curr...)
if len(curr) == len(keyGroups) {
combi := Combination{
Variants: make([]string, len(keyGroups)),
}
combining := make([]*btcec.PublicKey, len(keyGroups))
for g, altKeys := range keyGroups {
altKey := altKeys[curr[g]]
variant := secp256k1.PubKeyFormatCompressedEven
if altKey.Y().Bit(0) == 1 {
variant = secp256k1.PubKeyFormatCompressedOdd
}
combi.Variants[g] = hex.EncodeToString([]byte{variant})
combining[g] = altKey
}
agg, _, _, err := musig2.AggregateKeys(combining, true)
if err != nil {
log("error aggregating: %s", err)
return
}
serialized := agg.FinalKey.SerializeCompressed()
combi.Output.XOnly = hex.EncodeToString(serialized[1:])
combi.Output.Variant = hex.EncodeToString(serialized[0:1])
result.Combinations = append(result.Combinations, combi)
return
}
fn(0, curr)
if len(keyGroups[len(keyGroups)-len(curr)-1]) > 1 {
fn(1, curr)
}
}
fn(0, nil)
if len(keyGroups[len(keyGroups)-1]) > 1 {
fn(1, nil)
}
res, _ := json.MarshalIndent(result, "", " ")
stdout(string(res))
return nil
},
}
func getSecretKeysFromStdinLinesOrSlice(ctx context.Context, _ *cli.Command, keys []string) chan string {
ch := make(chan string)
go func() {
for sec := range getStdinLinesOrArgumentsFromSlice(keys) {
if sec == "" {
continue
}
if strings.HasPrefix(sec, "nsec1") {
_, data, err := nip19.Decode(sec)
if err != nil {
ctx = lineProcessingError(ctx, "invalid nsec code: %s", err)
continue
}
sec = data.(string)
}
sec = leftPadKey(sec)
if !nostr.IsValid32ByteHex(sec) {
ctx = lineProcessingError(ctx, "invalid hex key")
continue
}
ch <- sec
}
close(ch)
}()
return ch
}

144
main.go
View File

@@ -1,15 +1,27 @@
package main
import (
"fmt"
"context"
"net/http"
"net/textproto"
"os"
"path/filepath"
"github.com/urfave/cli/v2"
"github.com/fiatjaf/cli/v3"
"github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/sdk"
"github.com/nbd-wtf/go-nostr/sdk/hints/memoryh"
)
var app = &cli.App{
Name: "nak",
Usage: "the nostr army knife command-line tool",
var version string = "debug"
var app = &cli.Command{
Name: "nak",
Suggest: true,
UseShortOptionHandling: true,
AllowFlagsAfterArguments: true,
Usage: "the nostr army knife command-line tool",
DisableSliceFlagSeparator: true,
Commands: []*cli.Command{
req,
count,
@@ -17,13 +29,131 @@ var app = &cli.App{
event,
decode,
encode,
key,
verify,
relay,
bunker,
serve,
encrypt,
decrypt,
outbox,
wallet,
mcpServer,
curl,
},
Version: version,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "config-path",
Hidden: true,
Persistent: true,
},
&cli.BoolFlag{
Name: "quiet",
Usage: "do not print logs and info messages to stderr, use -qq to also not print anything to stdout",
Aliases: []string{"q"},
Persistent: true,
Action: func(ctx context.Context, c *cli.Command, b bool) error {
q := c.Count("quiet")
if q >= 1 {
log = func(msg string, args ...any) {}
if q >= 2 {
stdout = func(_ ...any) (int, error) { return 0, nil }
}
}
return nil
},
},
&cli.BoolFlag{
Name: "verbose",
Usage: "print more stuff than normally",
Aliases: []string{"v"},
Persistent: true,
Action: func(ctx context.Context, c *cli.Command, b bool) error {
v := c.Count("verbose")
if v >= 1 {
logverbose = log
}
return nil
},
},
},
Before: func(ctx context.Context, c *cli.Command) error {
configPath := c.String("config-path")
if configPath == "" {
if home, err := os.UserHomeDir(); err == nil {
configPath = filepath.Join(home, ".config/nak")
}
}
if configPath != "" {
hintsFilePath = filepath.Join(configPath, "outbox/hints.db")
}
if hintsFilePath != "" {
if _, err := os.Stat(hintsFilePath); !os.IsNotExist(err) {
hintsFileExists = true
}
}
if hintsFilePath != "" {
if data, err := os.ReadFile(hintsFilePath); err == nil {
hintsdb := memoryh.NewHintDB()
if err := json.Unmarshal(data, &hintsdb); err == nil {
sys = sdk.NewSystem(
sdk.WithHintsDB(hintsdb),
)
goto systemOperational
}
}
}
sys = sdk.NewSystem()
systemOperational:
sys.Pool = nostr.NewSimplePool(context.Background(),
nostr.WithAuthorKindQueryMiddleware(sys.TrackQueryAttempts),
nostr.WithEventMiddleware(sys.TrackEventHints),
nostr.WithRelayOptions(
nostr.WithRequestHeader(http.Header{textproto.CanonicalMIMEHeaderKey("user-agent"): {"nak/b"}}),
),
)
return nil
},
After: func(ctx context.Context, c *cli.Command) error {
// save hints database on exit
if hintsFileExists {
data, err := json.Marshal(sys.Hints)
if err != nil {
return err
}
return os.WriteFile(hintsFilePath, data, 0644)
}
return nil
},
}
func main() {
if err := app.Run(os.Args); err != nil {
fmt.Println(err)
defer colors.reset()
cli.VersionFlag = &cli.BoolFlag{
Name: "version",
Usage: "prints the version",
}
// a megahack to enable this curl command proxy
if len(os.Args) > 2 && os.Args[1] == "curl" {
if err := realCurl(); err != nil {
stdout(err)
colors.reset()
os.Exit(1)
}
return
}
if err := app.Run(context.Background(), os.Args); err != nil {
stdout(err)
colors.reset()
os.Exit(1)
}
}

217
mcp.go Normal file
View File

@@ -0,0 +1,217 @@
package main
import (
"context"
"fmt"
"os"
"strings"
"github.com/fiatjaf/cli/v3"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/nip19"
)
var mcpServer = &cli.Command{
Name: "mcp",
Usage: "pander to the AI gods",
Description: ``,
DisableSliceFlagSeparator: true,
Flags: []cli.Flag{},
Action: func(ctx context.Context, c *cli.Command) error {
s := server.NewMCPServer(
"nak",
version,
)
s.AddTool(mcp.NewTool("publish_note",
mcp.WithDescription("Publish a short note event to Nostr with the given text content"),
mcp.WithString("content",
mcp.Required(),
mcp.Description("Arbitrary string to be published"),
),
mcp.WithString("mention",
mcp.Required(),
mcp.Description("Nostr user's public key to be mentioned"),
),
), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
content, _ := request.Params.Arguments["content"].(string)
mention, _ := request.Params.Arguments["mention"].(string)
if mention != "" && !nostr.IsValidPublicKey(mention) {
return mcp.NewToolResultError("the given mention isn't a valid public key, it must be 32 bytes hex, like the ones returned by search_profile"), nil
}
sk := os.Getenv("NOSTR_SECRET_KEY")
if sk == "" {
sk = "0000000000000000000000000000000000000000000000000000000000000001"
}
var relays []string
evt := nostr.Event{
Kind: 1,
Tags: nostr.Tags{{"client", "goose/nak"}},
Content: content,
CreatedAt: nostr.Now(),
}
if mention != "" {
evt.Tags = append(evt.Tags, nostr.Tag{"p", mention})
// their inbox relays
relays = sys.FetchInboxRelays(ctx, mention, 3)
}
evt.Sign(sk)
// our write relays
relays = append(relays, sys.FetchOutboxRelays(ctx, evt.PubKey, 3)...)
if len(relays) == 0 {
relays = []string{"nos.lol", "relay.damus.io"}
}
for res := range sys.Pool.PublishMany(ctx, []string{"nos.lol"}, evt) {
if res.Error != nil {
return mcp.NewToolResultError(
fmt.Sprintf("there was an error publishing the event to the relay %s",
res.RelayURL),
), nil
}
}
return mcp.NewToolResultText("event was successfully published with id " + evt.ID), nil
})
s.AddTool(mcp.NewTool("resolve_nostr_uri",
mcp.WithDescription("Resolve URIs prefixed with nostr:, including nostr:nevent1..., nostr:npub1..., nostr:nprofile1... and nostr:naddr1..."),
mcp.WithString("uri",
mcp.Required(),
mcp.Description("URI to be resolved"),
),
), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
uri, _ := request.Params.Arguments["uri"].(string)
if strings.HasPrefix(uri, "nostr:") {
uri = uri[6:]
}
prefix, data, err := nip19.Decode(uri)
if err != nil {
return mcp.NewToolResultError("this Nostr uri is invalid"), nil
}
switch prefix {
case "npub":
pm := sys.FetchProfileMetadata(ctx, data.(string))
return mcp.NewToolResultText(
fmt.Sprintf("this is a Nostr profile named '%s', their public key is '%s'",
pm.ShortName(), pm.PubKey),
), nil
case "nprofile":
pm, _ := sys.FetchProfileFromInput(ctx, uri)
return mcp.NewToolResultText(
fmt.Sprintf("this is a Nostr profile named '%s', their public key is '%s'",
pm.ShortName(), pm.PubKey),
), nil
case "nevent":
event, _, err := sys.FetchSpecificEventFromInput(ctx, uri, false)
if err != nil {
return mcp.NewToolResultError("Couldn't find this event anywhere"), nil
}
return mcp.NewToolResultText(
fmt.Sprintf("this is a Nostr event: %s", event),
), nil
case "naddr":
return mcp.NewToolResultError("For now we can't handle this kind of Nostr uri"), nil
default:
return mcp.NewToolResultError("We don't know how to handle this Nostr uri"), nil
}
})
s.AddTool(mcp.NewTool("search_profile",
mcp.WithDescription("Search for the public key of a Nostr user given their name"),
mcp.WithString("name",
mcp.Required(),
mcp.Description("Name to be searched"),
),
), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
name, _ := request.Params.Arguments["name"].(string)
re := sys.Pool.QuerySingle(ctx, []string{"relay.nostr.band", "nostr.wine"}, nostr.Filter{Search: name, Kinds: []int{0}})
if re == nil {
return mcp.NewToolResultError("couldn't find anyone with that name"), nil
}
return mcp.NewToolResultText(re.PubKey), nil
})
s.AddTool(mcp.NewTool("get_outbox_relay_for_pubkey",
mcp.WithDescription("Get the best relay from where to read notes from a specific Nostr user"),
mcp.WithString("pubkey",
mcp.Required(),
mcp.Description("Public key of Nostr user we want to know the relay from where to read"),
),
), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
name, _ := request.Params.Arguments["name"].(string)
re := sys.Pool.QuerySingle(ctx, []string{"relay.nostr.band", "nostr.wine"}, nostr.Filter{Search: name, Kinds: []int{0}})
if re == nil {
return mcp.NewToolResultError("couldn't find anyone with that name"), nil
}
return mcp.NewToolResultText(re.PubKey), nil
})
s.AddTool(mcp.NewTool("read_events_from_relay",
mcp.WithDescription("Makes a REQ query to one relay using the specified parameters, this can be used to fetch notes from a profile"),
mcp.WithNumber("kind",
mcp.Required(),
mcp.Description("event kind number to include in the 'kinds' field"),
),
mcp.WithString("pubkey",
mcp.Description("pubkey to include in the 'authors' field"),
),
mcp.WithNumber("limit",
mcp.Required(),
mcp.Description("maximum number of events to query"),
),
mcp.WithString("relay",
mcp.Required(),
mcp.Description("relay URL to send the query to"),
),
), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
relay, _ := request.Params.Arguments["relay"].(string)
limit, _ := request.Params.Arguments["limit"].(int)
kind, _ := request.Params.Arguments["kind"].(int)
pubkey, _ := request.Params.Arguments["pubkey"].(string)
if pubkey != "" && !nostr.IsValidPublicKey(pubkey) {
return mcp.NewToolResultError("the given pubkey isn't a valid public key, it must be 32 bytes hex, like the ones returned by search_profile"), nil
}
filter := nostr.Filter{
Limit: limit,
Kinds: []int{kind},
}
if pubkey != "" {
filter.Authors = []string{pubkey}
}
events := sys.Pool.SubManyEose(ctx, []string{relay}, nostr.Filters{filter})
result := strings.Builder{}
for ie := range events {
result.WriteString("author public key: ")
result.WriteString(ie.PubKey)
result.WriteString("content: '")
result.WriteString(ie.Content)
result.WriteString("'")
result.WriteString("\n---\n")
}
return mcp.NewToolResultText(result.String()), nil
})
return server.ServeStdio(s)
},
}

355
musig2.go Normal file
View File

@@ -0,0 +1,355 @@
package main
import (
"bytes"
"context"
"encoding/base64"
"encoding/hex"
"fmt"
"strconv"
"strings"
"github.com/btcsuite/btcd/btcec/v2"
"github.com/btcsuite/btcd/btcec/v2/schnorr/musig2"
"github.com/nbd-wtf/go-nostr"
)
func getMusigAggregatedKey(_ context.Context, keys []string) (string, error) {
knownSigners := make([]*btcec.PublicKey, len(keys))
for i, spk := range keys {
bpk, err := hex.DecodeString(spk)
if err != nil {
return "", fmt.Errorf("'%s' is invalid hex: %w", spk, err)
}
if len(bpk) == 32 {
return "", fmt.Errorf("'%s' is missing the leading parity byte", spk)
}
pk, err := btcec.ParsePubKey(bpk)
if err != nil {
return "", fmt.Errorf("'%s' is not a valid pubkey: %w", spk, err)
}
knownSigners[i] = pk
}
aggpk, _, _, err := musig2.AggregateKeys(knownSigners, true)
if err != nil {
return "", fmt.Errorf("aggregation failed: %w", err)
}
return hex.EncodeToString(aggpk.FinalKey.SerializeCompressed()[1:]), nil
}
func performMusig(
_ context.Context,
sec string,
evt *nostr.Event,
numSigners int,
keys []string,
nonces []string,
secNonce string,
partialSigs []string,
) (signed bool, err error) {
// preprocess data received
secb, err := hex.DecodeString(sec)
if err != nil {
return false, err
}
seck, pubk := btcec.PrivKeyFromBytes(secb)
knownSigners := make([]*btcec.PublicKey, 0, numSigners)
includesUs := false
for _, hexpub := range keys {
bpub, err := hex.DecodeString(hexpub)
if err != nil {
return false, err
}
spub, err := btcec.ParsePubKey(bpub)
if err != nil {
return false, err
}
knownSigners = append(knownSigners, spub)
if spub.IsEqual(pubk) {
includesUs = true
}
}
if !includesUs {
knownSigners = append(knownSigners, pubk)
}
knownNonces := make([][66]byte, 0, numSigners)
for _, hexnonce := range nonces {
bnonce, err := hex.DecodeString(hexnonce)
if err != nil {
return false, err
}
if len(bnonce) != 66 {
return false, fmt.Errorf("nonce is not 66 bytes: %s", hexnonce)
}
var b66nonce [66]byte
copy(b66nonce[:], bnonce)
knownNonces = append(knownNonces, b66nonce)
}
knownPartialSigs := make([]*musig2.PartialSignature, 0, numSigners)
for _, hexps := range partialSigs {
bps, err := hex.DecodeString(hexps)
if err != nil {
return false, err
}
var ps musig2.PartialSignature
if err := ps.Decode(bytes.NewBuffer(bps)); err != nil {
return false, fmt.Errorf("invalid partial signature %s: %w", hexps, err)
}
knownPartialSigs = append(knownPartialSigs, &ps)
}
// create the context
var mctx *musig2.Context
if len(knownSigners) < numSigners {
// we don't know all the signers yet
mctx, err = musig2.NewContext(seck, true,
musig2.WithNumSigners(numSigners),
musig2.WithEarlyNonceGen(),
)
if err != nil {
return false, fmt.Errorf("failed to create signing context with %d unknown signers: %w",
numSigners, err)
}
} else {
// we know all the signers
mctx, err = musig2.NewContext(seck, true,
musig2.WithKnownSigners(knownSigners),
)
if err != nil {
return false, fmt.Errorf("failed to create signing context with %d known signers: %w",
len(knownSigners), err)
}
}
// nonce generation phase -- for sharing
if len(knownSigners) < numSigners {
// if we don't have all the signers we just generate a nonce and yield it to the next people
nonce, err := mctx.EarlySessionNonce()
if err != nil {
return false, err
}
log("the following code should be saved secretly until the next step an included with --musig-nonce-secret:\n")
log("%s\n\n", base64.StdEncoding.EncodeToString(nonce.SecNonce[:]))
knownNonces = append(knownNonces, nonce.PubNonce)
printPublicCommandForNextPeer(evt, numSigners, knownSigners, knownNonces, nil, false)
return false, nil
}
// if we got here we have all the pubkeys, so we can print the combined key
if comb, err := mctx.CombinedKey(); err != nil {
return false, fmt.Errorf("failed to combine keys (after %d signers): %w", len(knownSigners), err)
} else {
evt.PubKey = hex.EncodeToString(comb.SerializeCompressed()[1:])
evt.ID = evt.GetID()
log("combined key: %x\n\n", comb.SerializeCompressed())
}
// we have all the signers, which means we must also have all the nonces
var session *musig2.Session
if len(keys) == numSigners-1 {
// if we were the last to include our key, that means we have to include our nonce here to
// i.e. we didn't input our own pub nonce in the parameters
session, err = mctx.NewSession()
if err != nil {
return false, fmt.Errorf("failed to create session as the last peer to include our key: %w", err)
}
knownNonces = append(knownNonces, session.PublicNonce())
} else {
// otherwise we have included our own nonce in the parameters (from copypasting) but must
// also include the secret nonce that wasn't shared with peers
if secNonce == "" {
return false, fmt.Errorf("missing --musig-nonce-secret value")
}
secNonceB, err := base64.StdEncoding.DecodeString(secNonce)
if err != nil {
return false, fmt.Errorf("invalid --musig-nonce-secret: %w", err)
}
var secNonce97 [97]byte
copy(secNonce97[:], secNonceB)
session, err = mctx.NewSession(musig2.WithPreGeneratedNonce(&musig2.Nonces{
SecNonce: secNonce97,
PubNonce: secNonceToPubNonce(secNonce97),
}))
if err != nil {
return false, fmt.Errorf("failed to create signing session with secret nonce: %w", err)
}
}
var noncesOk bool
for _, b66nonce := range knownNonces {
if b66nonce == session.PublicNonce() {
// don't add our own nonce
continue
}
noncesOk, err = session.RegisterPubNonce(b66nonce)
if err != nil {
return false, fmt.Errorf("failed to register nonce: %w", err)
}
}
if !noncesOk {
return false, fmt.Errorf("we've registered all the nonces we had but at least one is missing, this shouldn't happen")
}
// signing phase
// we always have to sign, so let's do this
id := evt.GetID()
hash, _ := hex.DecodeString(id)
var msg32 [32]byte
copy(msg32[:], hash)
partialSig, err := session.Sign(msg32) // this will already include our sig in the bundle
if err != nil {
return false, fmt.Errorf("failed to produce partial signature: %w", err)
}
if len(knownPartialSigs)+1 < len(knownSigners) {
// still missing some signatures
knownPartialSigs = append(knownPartialSigs, partialSig) // we include ours here just so it's printed
printPublicCommandForNextPeer(evt, numSigners, knownSigners, knownNonces, knownPartialSigs, true)
return false, nil
} else {
// we have all signatures
for _, ps := range knownPartialSigs {
_, err = session.CombineSig(ps)
if err != nil {
return false, fmt.Errorf("failed to combine partial signature: %w", err)
}
}
}
// we have the signature
evt.Sig = hex.EncodeToString(session.FinalSig().Serialize())
return true, nil
}
func printPublicCommandForNextPeer(
evt *nostr.Event,
numSigners int,
knownSigners []*btcec.PublicKey,
knownNonces [][66]byte,
knownPartialSigs []*musig2.PartialSignature,
includeNonceSecret bool,
) {
maybeNonceSecret := ""
if includeNonceSecret {
maybeNonceSecret = " --musig-nonce-secret '<insert-nonce-secret>'"
}
log("the next signer and they should call this on their side:\nnak event --sec <insert-secret-key> --musig %d %s%s%s%s%s\n",
numSigners,
eventToCliArgs(evt),
signersToCliArgs(knownSigners),
noncesToCliArgs(knownNonces),
partialSigsToCliArgs(knownPartialSigs),
maybeNonceSecret,
)
}
func eventToCliArgs(evt *nostr.Event) string {
b := strings.Builder{}
b.Grow(100)
b.WriteString("-k ")
b.WriteString(strconv.Itoa(evt.Kind))
b.WriteString(" -ts ")
b.WriteString(strconv.FormatInt(int64(evt.CreatedAt), 10))
b.WriteString(" -c '")
b.WriteString(evt.Content)
b.WriteString("'")
for _, tag := range evt.Tags {
b.WriteString(" -t '")
b.WriteString(tag.Key())
if len(tag) > 1 {
b.WriteString("=")
b.WriteString(tag[1])
if len(tag) > 2 {
for _, item := range tag[2:] {
b.WriteString(";")
b.WriteString(item)
}
}
}
b.WriteString("'")
}
return b.String()
}
func signersToCliArgs(knownSigners []*btcec.PublicKey) string {
b := strings.Builder{}
b.Grow(len(knownSigners) * (16 + 66))
for _, signerPub := range knownSigners {
b.WriteString(" --musig-pubkey ")
b.WriteString(hex.EncodeToString(signerPub.SerializeCompressed()))
}
return b.String()
}
func noncesToCliArgs(knownNonces [][66]byte) string {
b := strings.Builder{}
b.Grow(len(knownNonces) * (15 + 132))
for _, nonce := range knownNonces {
b.WriteString(" --musig-nonce ")
b.WriteString(hex.EncodeToString(nonce[:]))
}
return b.String()
}
func partialSigsToCliArgs(knownPartialSigs []*musig2.PartialSignature) string {
b := strings.Builder{}
b.Grow(len(knownPartialSigs) * (17 + 64))
for _, partialSig := range knownPartialSigs {
b.WriteString(" --musig-partial ")
w := &bytes.Buffer{}
partialSig.Encode(w)
b.Write([]byte(hex.EncodeToString(w.Bytes())))
}
return b.String()
}
// this function is copied from btcec because it's not exported for some reason
func secNonceToPubNonce(secNonce [musig2.SecNonceSize]byte) [musig2.PubNonceSize]byte {
var k1Mod, k2Mod btcec.ModNScalar
k1Mod.SetByteSlice(secNonce[:btcec.PrivKeyBytesLen])
k2Mod.SetByteSlice(secNonce[btcec.PrivKeyBytesLen:])
var r1, r2 btcec.JacobianPoint
btcec.ScalarBaseMultNonConst(&k1Mod, &r1)
btcec.ScalarBaseMultNonConst(&k2Mod, &r2)
// Next, we'll convert the key in jacobian format to a normal public
// key expressed in affine coordinates.
r1.ToAffine()
r2.ToAffine()
r1Pub := btcec.NewPublicKey(&r1.X, &r1.Y)
r2Pub := btcec.NewPublicKey(&r2.X, &r2.Y)
var pubNonce [musig2.PubNonceSize]byte
// The public nonces are serialized as: R1 || R2, where both keys are
// serialized in compressed format.
copy(pubNonce[:], r1Pub.SerializeCompressed())
copy(
pubNonce[btcec.PubKeyBytesLenCompressed:],
r2Pub.SerializeCompressed(),
)
return pubNonce
}

68
outbox.go Normal file
View File

@@ -0,0 +1,68 @@
package main
import (
"context"
"fmt"
"os"
"path/filepath"
"github.com/fiatjaf/cli/v3"
"github.com/nbd-wtf/go-nostr"
)
var outbox = &cli.Command{
Name: "outbox",
Usage: "manage outbox relay hints database",
DisableSliceFlagSeparator: true,
Commands: []*cli.Command{
{
Name: "init",
Usage: "initialize the outbox hints database",
DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error {
if hintsFileExists {
return nil
}
if hintsFilePath == "" {
return fmt.Errorf("couldn't find a place to store the hints, pass --config-path to fix.")
}
if err := os.MkdirAll(filepath.Dir(hintsFilePath), 0777); err == nil {
if err := os.WriteFile(hintsFilePath, []byte("{}"), 0644); err != nil {
return fmt.Errorf("failed to create hints database: %w", err)
}
}
log("initialized hints database at %s\n", hintsFilePath)
return nil
},
},
{
Name: "list",
Usage: "list outbox relays for a given pubkey",
ArgsUsage: "<pubkey>",
DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error {
if !hintsFileExists {
log("running with temporary fragile data.\n")
log("call `nak outbox init` to setup persistence.\n")
}
if c.Args().Len() != 1 {
return fmt.Errorf("expected exactly one argument (pubkey)")
}
pubkey := c.Args().First()
if !nostr.IsValidPublicKey(pubkey) {
return fmt.Errorf("invalid public key: %s", pubkey)
}
for _, relay := range sys.FetchOutboxRelays(ctx, pubkey, 6) {
stdout(relay)
}
return nil
},
},
},
}

76
paginate.go Normal file
View File

@@ -0,0 +1,76 @@
package main
import (
"context"
"math"
"slices"
"time"
"github.com/nbd-wtf/go-nostr"
)
func paginateWithParams(
interval time.Duration,
globalLimit uint64,
) func(ctx context.Context, urls []string, filters nostr.Filters, opts ...nostr.SubscriptionOption) chan nostr.RelayEvent {
return func(ctx context.Context, urls []string, filters nostr.Filters, opts ...nostr.SubscriptionOption) chan nostr.RelayEvent {
// filters will always be just one
filter := filters[0]
nextUntil := nostr.Now()
if filter.Until != nil {
nextUntil = *filter.Until
}
if globalLimit == 0 {
globalLimit = uint64(filter.Limit)
if globalLimit == 0 && !filter.LimitZero {
globalLimit = math.MaxUint64
}
}
var globalCount uint64 = 0
globalCh := make(chan nostr.RelayEvent)
repeatedCache := make([]string, 0, 300)
nextRepeatedCache := make([]string, 0, 300)
go func() {
defer close(globalCh)
for {
filter.Until = &nextUntil
time.Sleep(interval)
keepGoing := false
for evt := range sys.Pool.SubManyEose(ctx, urls, nostr.Filters{filter}, opts...) {
if slices.Contains(repeatedCache, evt.ID) {
continue
}
keepGoing = true // if we get one that isn't repeated, then keep trying to get more
nextRepeatedCache = append(nextRepeatedCache, evt.ID)
globalCh <- evt
globalCount++
if globalCount >= globalLimit {
return
}
if evt.CreatedAt < *filter.Until {
nextUntil = evt.CreatedAt
}
}
if !keepGoing {
return
}
repeatedCache = nextRepeatedCache
nextRepeatedCache = nextRepeatedCache[:0]
}
}()
return globalCh
}
}

205
relay.go Normal file
View File

@@ -0,0 +1,205 @@
package main
import (
"bytes"
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"io"
"net/http"
"github.com/fiatjaf/cli/v3"
"github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/nip11"
"github.com/nbd-wtf/go-nostr/nip86"
)
var relay = &cli.Command{
Name: "relay",
Usage: "gets the relay information document for the given relay, as JSON -- or allows usage of the relay management API.",
Description: `examples:
fetching relay information:
nak relay nostr.wine
managing a relay
nak relay nostr.wine banevent --sec 1234 --id 037eb3751073770ff17483b1b1ff125866cd5147668271975ef0a8a8e7ee184a --reason "I don't like it"`,
ArgsUsage: "<relay-url>",
DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error {
for url := range getStdinLinesOrArguments(c.Args()) {
if url == "" {
return fmt.Errorf("specify the <relay-url>")
}
info, err := nip11.Fetch(ctx, url)
if err != nil {
ctx = lineProcessingError(ctx, "failed to fetch '%s' information document: %w", url, err)
continue
}
pretty, _ := json.MarshalIndent(info, "", " ")
stdout(string(pretty))
}
return nil
},
Commands: (func() []*cli.Command {
commands := make([]*cli.Command, 0, 12)
for _, def := range []struct {
method string
args []string
}{
{"allowpubkey", []string{"pubkey", "reason"}},
{"banpubkey", []string{"pubkey", "reason"}},
{"listallowedpubkeys", nil},
{"allowpubkey", []string{"pubkey", "reason"}},
{"listallowedpubkeys", nil},
{"listeventsneedingmoderation", nil},
{"allowevent", []string{"id", "reason"}},
{"banevent", []string{"id", "reason"}},
{"listbannedevents", nil},
{"changerelayname", []string{"name"}},
{"changerelaydescription", []string{"description"}},
{"changerelayicon", []string{"icon"}},
{"allowkind", []string{"kind"}},
{"disallowkind", []string{"kind"}},
{"listallowedkinds", nil},
{"blockip", []string{"ip", "reason"}},
{"unblockip", []string{"ip", "reason"}},
{"listblockedips", nil},
} {
def := def
flags := make([]cli.Flag, len(def.args), len(def.args)+4)
for i, argName := range def.args {
flags[i] = declareFlag(argName)
}
flags = append(flags, defaultKeyFlags...)
cmd := &cli.Command{
Name: def.method,
Usage: fmt.Sprintf(`the "%s" relay management RPC call`, def.method),
Description: fmt.Sprintf(
`the "%s" management RPC call, see https://nips.nostr.com/86 for more information`, def.method),
Action: func(ctx context.Context, c *cli.Command) error {
params := make([]any, len(def.args))
for i, argName := range def.args {
params[i] = getArgument(c, argName)
}
req := nip86.Request{Method: def.method, Params: params}
reqj, _ := json.Marshal(req)
relayUrls := c.Args().Slice()
if len(relayUrls) == 0 {
stdout(string(reqj))
return nil
}
kr, _, err := gatherKeyerFromArguments(ctx, c)
if err != nil {
return err
}
for _, relayUrl := range relayUrls {
httpUrl := "http" + nostr.NormalizeURL(relayUrl)[2:]
log("calling '%s' on %s... ", def.method, httpUrl)
body := bytes.NewBuffer(nil)
body.Write(reqj)
req, err := http.NewRequestWithContext(ctx, "POST", httpUrl, body)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
// Authorization
payloadHash := sha256.Sum256(reqj)
tokenEvent := nostr.Event{
Kind: 27235,
CreatedAt: nostr.Now(),
Tags: nostr.Tags{
{"u", httpUrl},
{"method", "POST"},
{"payload", hex.EncodeToString(payloadHash[:])},
},
}
if err := kr.SignEvent(ctx, &tokenEvent); err != nil {
return fmt.Errorf("failed to sign token event: %w", err)
}
evtj, _ := json.Marshal(tokenEvent)
req.Header.Set("Authorization", "Nostr "+base64.StdEncoding.EncodeToString(evtj))
// Content-Type
req.Header.Set("Content-Type", "application/nostr+json+rpc")
// make request to relay
resp, err := http.DefaultClient.Do(req)
if err != nil {
log("failed: %s\n", err)
continue
}
b, err := io.ReadAll(resp.Body)
if err != nil {
log("failed to read response: %s\n", err)
continue
}
if resp.StatusCode >= 300 {
log("failed with status %d\n", resp.StatusCode)
bodyPrintable := string(b)
if len(bodyPrintable) > 300 {
bodyPrintable = bodyPrintable[0:297] + "..."
}
log(bodyPrintable)
continue
}
var response nip86.Response
if err := json.Unmarshal(b, &response); err != nil {
log("bad json response: %s\n", err)
bodyPrintable := string(b)
if len(bodyPrintable) > 300 {
bodyPrintable = bodyPrintable[0:297] + "..."
}
log(bodyPrintable)
continue
}
resp.Body.Close()
// print the result
log("\n")
pretty, _ := json.MarshalIndent(response, "", " ")
stdout(string(pretty))
}
return nil
},
Flags: flags,
}
commands = append(commands, cmd)
}
return commands
})(),
}
func declareFlag(argName string) cli.Flag {
usage := "parameter for this management RPC call, see https://nips.nostr.com/86 for more information."
switch argName {
case "kind":
return &cli.IntFlag{Name: argName, Required: true, Usage: usage}
case "reason":
return &cli.StringFlag{Name: argName, Usage: usage}
default:
return &cli.StringFlag{Name: argName, Required: true, Usage: usage}
}
}
func getArgument(c *cli.Command, argName string) any {
switch argName {
case "kind":
return c.Int(argName)
default:
return c.String(argName)
}
}

369
req.go
View File

@@ -1,20 +1,25 @@
package main
import (
"encoding/json"
"context"
"fmt"
"os"
"strings"
"github.com/fiatjaf/cli/v3"
"github.com/mailru/easyjson"
"github.com/nbd-wtf/go-nostr"
"github.com/urfave/cli/v2"
)
const CATEGORY_FILTER_ATTRIBUTES = "FILTER ATTRIBUTES"
const (
CATEGORY_FILTER_ATTRIBUTES = "FILTER ATTRIBUTES"
// CATEGORY_SIGNER = "SIGNER OPTIONS" -- defined at event.go as the same (yes, I know)
)
var req = &cli.Command{
Name: "req",
Usage: "generates encoded REQ messages and optionally use them to talk to relays",
Description: `outputs a NIP-01 Nostr filter. when a relay is not given, will print the filter, otherwise will connect to the given relay and send the filter.
Description: `outputs a nip01 Nostr filter. when a relay is not given, will print the filter, otherwise will connect to the given relay and send the filter.
example:
nak req -k 1 -l 15 wss://nostr.wine wss://nostr-pub.wellorder.net
@@ -23,147 +28,108 @@ example:
it can also take a filter from stdin, optionally modify it with flags and send it to specific relays (or just print it).
example:
echo '{"kinds": [1], "#t": ["test"]}' | nak req -l 5 -k 4549 --tag t=spam wss://nostr-pub.wellorder.net
`,
Flags: []cli.Flag{
&cli.StringSliceFlag{
Name: "author",
Aliases: []string{"a"},
Usage: "only accept events from these authors (pubkey as hex)",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&cli.StringSliceFlag{
Name: "id",
Aliases: []string{"i"},
Usage: "only accept events with these ids (hex)",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&cli.IntSliceFlag{
Name: "kind",
Aliases: []string{"k"},
Usage: "only accept events with these kind numbers",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&cli.StringSliceFlag{
Name: "tag",
Aliases: []string{"t"},
Usage: "takes a tag like -t e=<id>, only accept events with these tags",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&cli.StringSliceFlag{
Name: "e",
Usage: "shortcut for --tag e=<value>",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&cli.StringSliceFlag{
Name: "p",
Usage: "shortcut for --tag p=<value>",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&cli.IntFlag{
Name: "since",
Aliases: []string{"s"},
Usage: "only accept events newer than this (unix timestamp)",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&cli.IntFlag{
Name: "until",
Aliases: []string{"u"},
Usage: "only accept events older than this (unix timestamp)",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&cli.IntFlag{
Name: "limit",
Aliases: []string{"l"},
Usage: "only accept up to this number of events",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&cli.StringFlag{
Name: "search",
Usage: "a NIP-50 search query, use it only with relays that explicitly support it",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&cli.BoolFlag{
Name: "bare",
Usage: "when printing the filter, print just the filter, not enveloped in a [\"REQ\", ...] array",
},
&cli.BoolFlag{
Name: "stream",
Usage: "keep the subscription open, printing all events as they are returned",
DefaultText: "false, will close on EOSE",
},
},
echo '{"kinds": [1], "#t": ["test"]}' | nak req -l 5 -k 4549 --tag t=spam wss://nostr-pub.wellorder.net`,
DisableSliceFlagSeparator: true,
Flags: append(defaultKeyFlags,
append(reqFilterFlags,
&cli.BoolFlag{
Name: "stream",
Usage: "keep the subscription open, printing all events as they are returned",
DefaultText: "false, will close on EOSE",
},
&cli.BoolFlag{
Name: "paginate",
Usage: "make multiple REQs to the relay decreasing the value of 'until' until 'limit' or 'since' conditions are met",
DefaultText: "false",
},
&cli.DurationFlag{
Name: "paginate-interval",
Usage: "time between queries when using --paginate",
},
&cli.UintFlag{
Name: "paginate-global-limit",
Usage: "global limit at which --paginate should stop",
DefaultText: "uses the value given by --limit/-l or infinite",
},
&cli.BoolFlag{
Name: "bare",
Usage: "when printing the filter, print just the filter, not enveloped in a [\"REQ\", ...] array",
},
&cli.BoolFlag{
Name: "auth",
Usage: "always perform nip42 \"AUTH\" when facing an \"auth-required: \" rejection and try again",
},
&cli.BoolFlag{
Name: "force-pre-auth",
Aliases: []string{"fpa"},
Usage: "after connecting, for a nip42 \"AUTH\" message to be received, act on it and only then send the \"REQ\"",
Category: CATEGORY_SIGNER,
},
)...,
),
ArgsUsage: "[relay...]",
Action: func(c *cli.Context) error {
for stdinFilter := range getStdinLinesOrBlank() {
Action: func(ctx context.Context, c *cli.Command) error {
relayUrls := c.Args().Slice()
if len(relayUrls) > 0 {
relays := connectToAllRelays(ctx,
relayUrls,
c.Bool("force-pre-auth"),
nostr.WithAuthHandler(
func(ctx context.Context, authEvent nostr.RelayEvent) error {
if !c.Bool("auth") && !c.Bool("force-pre-auth") {
return fmt.Errorf("auth not authorized")
}
kr, _, err := gatherKeyerFromArguments(ctx, c)
if err != nil {
return err
}
pk, _ := kr.GetPublicKey(ctx)
log("performing auth as %s... ", pk)
return kr.SignEvent(ctx, authEvent.Event)
},
),
)
if len(relays) == 0 {
log("failed to connect to any of the given relays.\n")
os.Exit(3)
}
relayUrls = make([]string, len(relays))
for i, relay := range relays {
relayUrls[i] = relay.URL
}
defer func() {
for _, relay := range relays {
relay.Close()
}
}()
}
for stdinFilter := range getJsonsOrBlank() {
filter := nostr.Filter{}
if stdinFilter != "" {
if err := json.Unmarshal([]byte(stdinFilter), &filter); err != nil {
lineProcessingError(c, "invalid filter '%s' received from stdin: %s", stdinFilter, err)
if err := easyjson.Unmarshal([]byte(stdinFilter), &filter); err != nil {
ctx = lineProcessingError(ctx, "invalid filter '%s' received from stdin: %s", stdinFilter, err)
continue
}
}
if authors := c.StringSlice("author"); len(authors) > 0 {
filter.Authors = append(filter.Authors, authors...)
if err := applyFlagsToFilter(c, &filter); err != nil {
return err
}
if ids := c.StringSlice("id"); len(ids) > 0 {
filter.IDs = append(filter.IDs, ids...)
}
if kinds := c.IntSlice("kind"); len(kinds) > 0 {
filter.Kinds = append(filter.Kinds, kinds...)
}
if search := c.String("search"); search != "" {
filter.Search = search
}
tags := make([][]string, 0, 5)
for _, tagFlag := range c.StringSlice("tag") {
spl := strings.Split(tagFlag, "=")
if len(spl) == 2 && len(spl[0]) == 1 {
tags = append(tags, spl)
} else {
return fmt.Errorf("invalid --tag '%s'", tagFlag)
if len(relayUrls) > 0 {
fn := sys.Pool.SubManyEose
if c.Bool("paginate") {
fn = paginateWithParams(c.Duration("paginate-interval"), c.Uint("paginate-global-limit"))
} else if c.Bool("stream") {
fn = sys.Pool.SubMany
}
}
for _, etag := range c.StringSlice("e") {
tags = append(tags, []string{"e", etag})
}
for _, ptag := range c.StringSlice("p") {
tags = append(tags, []string{"p", ptag})
}
if len(tags) > 0 && filter.Tags == nil {
filter.Tags = make(nostr.TagMap)
}
for _, tag := range tags {
if _, ok := filter.Tags[tag[0]]; !ok {
filter.Tags[tag[0]] = make([]string, 0, 3)
}
filter.Tags[tag[0]] = append(filter.Tags[tag[0]], tag[1])
}
if since := c.Int("since"); since != 0 {
ts := nostr.Timestamp(since)
filter.Since = &ts
}
if until := c.Int("until"); until != 0 {
ts := nostr.Timestamp(until)
filter.Until = &ts
}
if limit := c.Int("limit"); limit != 0 {
filter.Limit = limit
}
relays := c.Args().Slice()
if len(relays) > 0 {
pool := nostr.NewSimplePool(c.Context)
fn := pool.SubManyEose
if c.Bool("stream") {
fn = pool.SubMany
}
for ie := range fn(c.Context, relays, nostr.Filters{filter}) {
fmt.Println(ie.Event)
for ie := range fn(ctx, relayUrls, nostr.Filters{filter}) {
stdout(ie.Event)
}
} else {
// no relays given, will just print the filter
@@ -171,15 +137,142 @@ example:
if c.Bool("bare") {
result = filter.String()
} else {
j, _ := json.Marshal([]any{"REQ", "nak", filter})
j, _ := json.Marshal(nostr.ReqEnvelope{SubscriptionID: "nak", Filters: nostr.Filters{filter}})
result = string(j)
}
fmt.Println(result)
stdout(result)
}
}
exitIfLineProcessingError(c)
exitIfLineProcessingError(ctx)
return nil
},
}
var reqFilterFlags = []cli.Flag{
&cli.StringSliceFlag{
Name: "author",
Aliases: []string{"a"},
Usage: "only accept events from these authors (pubkey as hex)",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&cli.StringSliceFlag{
Name: "id",
Aliases: []string{"i"},
Usage: "only accept events with these ids (hex)",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&cli.IntSliceFlag{
Name: "kind",
Aliases: []string{"k"},
Usage: "only accept events with these kind numbers",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&cli.StringSliceFlag{
Name: "tag",
Aliases: []string{"t"},
Usage: "takes a tag like -t e=<id>, only accept events with these tags",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&cli.StringSliceFlag{
Name: "e",
Usage: "shortcut for --tag e=<value>",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&cli.StringSliceFlag{
Name: "p",
Usage: "shortcut for --tag p=<value>",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&cli.StringSliceFlag{
Name: "d",
Usage: "shortcut for --tag d=<value>",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&NaturalTimeFlag{
Name: "since",
Aliases: []string{"s"},
Usage: "only accept events newer than this (unix timestamp)",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&NaturalTimeFlag{
Name: "until",
Aliases: []string{"u"},
Usage: "only accept events older than this (unix timestamp)",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&cli.UintFlag{
Name: "limit",
Aliases: []string{"l"},
Usage: "only accept up to this number of events",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
&cli.StringFlag{
Name: "search",
Usage: "a nip50 search query, use it only with relays that explicitly support it",
Category: CATEGORY_FILTER_ATTRIBUTES,
},
}
func applyFlagsToFilter(c *cli.Command, filter *nostr.Filter) error {
if authors := c.StringSlice("author"); len(authors) > 0 {
filter.Authors = append(filter.Authors, authors...)
}
if ids := c.StringSlice("id"); len(ids) > 0 {
filter.IDs = append(filter.IDs, ids...)
}
for _, kind64 := range c.IntSlice("kind") {
filter.Kinds = append(filter.Kinds, int(kind64))
}
if search := c.String("search"); search != "" {
filter.Search = search
}
tags := make([][]string, 0, 5)
for _, tagFlag := range c.StringSlice("tag") {
spl := strings.SplitN(tagFlag, "=", 2)
if len(spl) == 2 {
tags = append(tags, spl)
} else {
return fmt.Errorf("invalid --tag '%s'", tagFlag)
}
}
for _, etag := range c.StringSlice("e") {
tags = append(tags, []string{"e", etag})
}
for _, ptag := range c.StringSlice("p") {
tags = append(tags, []string{"p", ptag})
}
for _, dtag := range c.StringSlice("d") {
tags = append(tags, []string{"d", dtag})
}
if len(tags) > 0 && filter.Tags == nil {
filter.Tags = make(nostr.TagMap)
}
for _, tag := range tags {
if _, ok := filter.Tags[tag[0]]; !ok {
filter.Tags[tag[0]] = make([]string, 0, 3)
}
filter.Tags[tag[0]] = append(filter.Tags[tag[0]], tag[1])
}
if c.IsSet("since") {
nts := getNaturalDate(c, "since")
filter.Since = &nts
}
if c.IsSet("until") {
nts := getNaturalDate(c, "until")
filter.Until = &nts
}
if limit := c.Uint("limit"); limit != 0 {
filter.Limit = int(limit)
} else if c.IsSet("limit") {
filter.LimitZero = true
}
return nil
}

122
serve.go Normal file
View File

@@ -0,0 +1,122 @@
package main
import (
"bufio"
"context"
"fmt"
"math"
"os"
"time"
"github.com/bep/debounce"
"github.com/fatih/color"
"github.com/fiatjaf/cli/v3"
"github.com/fiatjaf/eventstore/slicestore"
"github.com/fiatjaf/khatru"
"github.com/nbd-wtf/go-nostr"
)
var serve = &cli.Command{
Name: "serve",
Usage: "starts an in-memory relay for testing purposes",
DisableSliceFlagSeparator: true,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "hostname",
Usage: "hostname where to listen for connections",
Value: "localhost",
},
&cli.UintFlag{
Name: "port",
Usage: "port where to listen for connections",
Value: 10547,
},
&cli.StringFlag{
Name: "events",
Usage: "file containing the initial batch of events that will be served by the relay as newline-separated JSON (jsonl)",
DefaultText: "the relay will start empty",
},
},
Action: func(ctx context.Context, c *cli.Command) error {
db := slicestore.SliceStore{MaxLimit: math.MaxInt}
var scanner *bufio.Scanner
if path := c.String("events"); path != "" {
f, err := os.Open(path)
if err != nil {
return fmt.Errorf("failed to file at '%s': %w", path, err)
}
scanner = bufio.NewScanner(f)
} else if isPiped() {
scanner = bufio.NewScanner(os.Stdin)
}
if scanner != nil {
scanner.Buffer(make([]byte, 16*1024*1024), 256*1024*1024)
i := 0
for scanner.Scan() {
var evt nostr.Event
if err := json.Unmarshal(scanner.Bytes(), &evt); err != nil {
return fmt.Errorf("invalid event received at line %d: %s (`%s`)", i, err, scanner.Text())
}
db.SaveEvent(ctx, &evt)
i++
}
}
rl := khatru.NewRelay()
rl.QueryEvents = append(rl.QueryEvents, db.QueryEvents)
rl.CountEvents = append(rl.CountEvents, db.CountEvents)
rl.DeleteEvent = append(rl.DeleteEvent, db.DeleteEvent)
rl.StoreEvent = append(rl.StoreEvent, db.SaveEvent)
started := make(chan bool)
exited := make(chan error)
hostname := c.String("hostname")
port := int(c.Uint("port"))
go func() {
err := rl.Start(hostname, port, started)
exited <- err
}()
var printStatus func()
// relay logging
rl.RejectFilter = append(rl.RejectFilter, func(ctx context.Context, filter nostr.Filter) (reject bool, msg string) {
log(" got %s %v\n", color.HiYellowString("request"), colors.italic(filter))
printStatus()
return false, ""
})
rl.RejectCountFilter = append(rl.RejectCountFilter, func(ctx context.Context, filter nostr.Filter) (reject bool, msg string) {
log(" got %s %v\n", color.HiCyanString("count request"), colors.italic(filter))
printStatus()
return false, ""
})
rl.RejectEvent = append(rl.RejectEvent, func(ctx context.Context, event *nostr.Event) (reject bool, msg string) {
log(" got %s %v\n", color.BlueString("event"), colors.italic(event))
printStatus()
return false, ""
})
d := debounce.New(time.Second * 2)
printStatus = func() {
d(func() {
totalEvents := 0
ch, _ := db.QueryEvents(ctx, nostr.Filter{})
for range ch {
totalEvents++
}
subs := rl.GetListeningFilters()
log(" %s events stored: %s, subscriptions opened: %s\n", color.HiMagentaString("•"), color.HiMagentaString("%d", totalEvents), color.HiMagentaString("%d", len(subs)))
})
}
<-started
log("%s relay running at %s\n", color.HiRedString(">"), colors.boldf("ws://%s:%d", hostname, port))
return <-exited
},
}

View File

@@ -1,10 +1,10 @@
package main
import (
"encoding/json"
"context"
"github.com/fiatjaf/cli/v3"
"github.com/nbd-wtf/go-nostr"
"github.com/urfave/cli/v2"
)
var verify = &cli.Command{
@@ -13,30 +13,30 @@ var verify = &cli.Command{
Description: `example:
echo '{"id":"a889df6a387419ff204305f4c2d296ee328c3cd4f8b62f205648a541b4554dfb","pubkey":"c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5","created_at":1698623783,"kind":1,"tags":[],"content":"hello from the nostr army knife","sig":"84876e1ee3e726da84e5d195eb79358b2b3eaa4d9bd38456fde3e8a2af3f1cd4cda23f23fda454869975b3688797d4c66e12f4c51c1b43c6d2997c5e61865661"}' | nak verify
it outputs nothing if the verification is successful.
`,
Action: func(c *cli.Context) error {
for stdinEvent := range getStdinLinesOrBlank() {
it outputs nothing if the verification is successful.`,
DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error {
for stdinEvent := range getStdinLinesOrArguments(c.Args()) {
evt := nostr.Event{}
if stdinEvent != "" {
if err := json.Unmarshal([]byte(stdinEvent), &evt); err != nil {
lineProcessingError(c, "invalid event: %s", err)
ctx = lineProcessingError(ctx, "invalid event: %s", err)
continue
}
}
if evt.GetID() != evt.ID {
lineProcessingError(c, "invalid .id, expected %s, got %s", evt.GetID(), evt.ID)
ctx = lineProcessingError(ctx, "invalid .id, expected %s, got %s", evt.GetID(), evt.ID)
continue
}
if ok, err := evt.CheckSignature(); !ok {
lineProcessingError(c, "invalid signature: %s", err)
ctx = lineProcessingError(ctx, "invalid signature: %v", err)
continue
}
}
exitIfLineProcessingError(c)
exitIfLineProcessingError(ctx)
return nil
},
}

487
wallet.go Normal file
View File

@@ -0,0 +1,487 @@
package main
import (
"context"
"fmt"
"strconv"
"strings"
"github.com/fatih/color"
"github.com/fiatjaf/cli/v3"
"github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/nip60"
"github.com/nbd-wtf/go-nostr/nip61"
)
func prepareWallet(ctx context.Context, c *cli.Command) (*nip60.Wallet, func(), error) {
kr, _, err := gatherKeyerFromArguments(ctx, c)
if err != nil {
return nil, nil, err
}
pk, err := kr.GetPublicKey(ctx)
if err != nil {
return nil, nil, err
}
relays := sys.FetchOutboxRelays(ctx, pk, 3)
w := nip60.LoadWallet(ctx, kr, sys.Pool, relays)
if w == nil {
return nil, nil, fmt.Errorf("error loading walle")
}
w.Processed = func(evt *nostr.Event, err error) {
if err == nil {
logverbose("processed event %s\n", evt)
} else {
log("error processing event %s: %s\n", evt, err)
}
}
w.PublishUpdate = func(event nostr.Event, deleted, received, change *nip60.Token, isHistory bool) {
desc := "wallet"
if received != nil {
mint, _ := strings.CutPrefix(received.Mint, "https://")
desc = fmt.Sprintf("received from %s with %d proofs totalling %d",
mint, len(received.Proofs), received.Proofs.Amount())
} else if change != nil {
mint, _ := strings.CutPrefix(change.Mint, "https://")
desc = fmt.Sprintf("change from %s with %d proofs totalling %d",
mint, len(change.Proofs), change.Proofs.Amount())
} else if deleted != nil {
mint, _ := strings.CutPrefix(deleted.Mint, "https://")
desc = fmt.Sprintf("deleting a used token from %s with %d proofs totalling %d",
mint, len(deleted.Proofs), deleted.Proofs.Amount())
} else if isHistory {
desc = "history entry"
}
log("- saving kind:%d event (%s)... ", event.Kind, desc)
first := true
for res := range sys.Pool.PublishMany(ctx, relays, event) {
cleanUrl, ok := strings.CutPrefix(res.RelayURL, "wss://")
if !ok {
cleanUrl = res.RelayURL
}
if !first {
log(", ")
}
first = false
if res.Error != nil {
log("%s: %s", color.RedString(cleanUrl), res.Error)
} else {
log("%s: ok", color.GreenString(cleanUrl))
}
}
log("\n")
}
<-w.Stable
return w, func() {
w.Close()
}, nil
}
var wallet = &cli.Command{
Name: "wallet",
Usage: "displays the current wallet balance",
Description: "all wallet data is stored on Nostr relays, signed and encrypted with the given key, and reloaded again from relays on every call.\n\nthe same data can be accessed by other compatible nip60 clients.",
DisableSliceFlagSeparator: true,
Flags: defaultKeyFlags,
Action: func(ctx context.Context, c *cli.Command) error {
w, closew, err := prepareWallet(ctx, c)
if err != nil {
return err
}
stdout(w.Balance())
closew()
return nil
},
Commands: []*cli.Command{
{
Name: "mints",
Usage: "lists, adds or remove default mints from the wallet",
DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error {
w, closew, err := prepareWallet(ctx, c)
if err != nil {
return err
}
for _, url := range w.Mints {
stdout(strings.Split(url, "://")[1])
}
closew()
return nil
},
Commands: []*cli.Command{
{
Name: "add",
DisableSliceFlagSeparator: true,
ArgsUsage: "<mint>...",
Action: func(ctx context.Context, c *cli.Command) error {
w, closew, err := prepareWallet(ctx, c)
if err != nil {
return err
}
if err := w.AddMint(ctx, c.Args().Slice()...); err != nil {
return err
}
closew()
return nil
},
},
{
Name: "remove",
DisableSliceFlagSeparator: true,
ArgsUsage: "<mint>...",
Action: func(ctx context.Context, c *cli.Command) error {
w, closew, err := prepareWallet(ctx, c)
if err != nil {
return err
}
if err := w.RemoveMint(ctx, c.Args().Slice()...); err != nil {
return err
}
closew()
return nil
},
},
},
},
{
Name: "tokens",
Usage: "lists existing tokens with their mints and aggregated amounts",
DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error {
w, closew, err := prepareWallet(ctx, c)
if err != nil {
return err
}
for _, token := range w.Tokens {
stdout(token.ID(), token.Proofs.Amount(), strings.Split(token.Mint, "://")[1])
}
closew()
return nil
},
},
{
Name: "receive",
Usage: "takes a cashu token string as an argument and adds it to the wallet",
ArgsUsage: "<token>",
DisableSliceFlagSeparator: true,
Flags: []cli.Flag{
&cli.StringSliceFlag{
Name: "mint",
Usage: "mint to swap the token into",
},
},
Action: func(ctx context.Context, c *cli.Command) error {
args := c.Args().Slice()
if len(args) != 1 {
return fmt.Errorf("must be called as `nak wallet receive <token>")
}
w, closew, err := prepareWallet(ctx, c)
if err != nil {
return err
}
proofs, mint, err := nip60.GetProofsAndMint(args[0])
if err != nil {
return err
}
opts := make([]nip60.ReceiveOption, 0, 1)
for _, url := range c.StringSlice("mint") {
opts = append(opts, nip60.WithMintDestination(url))
}
if err := w.Receive(ctx, proofs, mint, opts...); err != nil {
return err
}
closew()
return nil
},
},
{
Name: "send",
Usage: "prints a cashu token with the given amount for sending to someone else",
ArgsUsage: "<amount>",
DisableSliceFlagSeparator: true,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "mint",
Usage: "send from a specific mint",
},
},
Action: func(ctx context.Context, c *cli.Command) error {
args := c.Args().Slice()
if len(args) != 1 {
return fmt.Errorf("must be called as `nak wallet send <amount>")
}
amount, err := strconv.ParseUint(args[0], 10, 64)
if err != nil {
return fmt.Errorf("amount '%s' is invalid", args[0])
}
w, closew, err := prepareWallet(ctx, c)
if err != nil {
return err
}
opts := make([]nip60.SendOption, 0, 1)
if mint := c.String("mint"); mint != "" {
mint = "http" + nostr.NormalizeURL(mint)[2:]
opts = append(opts, nip60.WithMint(mint))
}
proofs, mint, err := w.Send(ctx, amount, opts...)
if err != nil {
return err
}
stdout(nip60.MakeTokenString(proofs, mint))
closew()
return nil
},
},
{
Name: "pay",
Usage: "pays a bolt11 lightning invoice and outputs the preimage",
ArgsUsage: "<invoice>",
DisableSliceFlagSeparator: true,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "mint",
Usage: "pay from a specific mint",
},
},
Action: func(ctx context.Context, c *cli.Command) error {
args := c.Args().Slice()
if len(args) != 1 {
return fmt.Errorf("must be called as `nak wallet pay <invoice>")
}
w, closew, err := prepareWallet(ctx, c)
if err != nil {
return err
}
opts := make([]nip60.SendOption, 0, 1)
if mint := c.String("mint"); mint != "" {
mint = "http" + nostr.NormalizeURL(mint)[2:]
opts = append(opts, nip60.WithMint(mint))
}
preimage, err := w.PayBolt11(ctx, args[0], opts...)
if err != nil {
return err
}
stdout(preimage)
closew()
return nil
},
},
{
Name: "nutzap",
Usage: "sends a nip61 nutzap to one or more Nostr profiles and/or events",
ArgsUsage: "<amount> <target>",
Description: "<amount> is in satoshis, <target> can be an npub, nprofile, nevent or hex pubkey.",
DisableSliceFlagSeparator: true,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "mint",
Usage: "send from a specific mint",
},
&cli.StringFlag{
Name: "message",
Usage: "attach a message to the nutzap",
},
},
Action: func(ctx context.Context, c *cli.Command) error {
args := c.Args().Slice()
if len(args) >= 2 {
return fmt.Errorf("must be called as `nak wallet send <amount> <target>...")
}
w, closew, err := prepareWallet(ctx, c)
if err != nil {
return err
}
amount := c.Uint("amount")
target := c.String("target")
var evt *nostr.Event
var eventId string
if strings.HasPrefix(target, "nevent1") {
evt, _, err = sys.FetchSpecificEventFromInput(ctx, target, false)
if err != nil {
return err
}
eventId = evt.ID
target = evt.PubKey
}
pm, err := sys.FetchProfileFromInput(ctx, target)
if err != nil {
return err
}
log("sending %d sat to '%s' (%s)", amount, pm.ShortName(), pm.Npub())
opts := make([]nip60.SendOption, 0, 1)
if mint := c.String("mint"); mint != "" {
mint = "http" + nostr.NormalizeURL(mint)[2:]
opts = append(opts, nip60.WithMint(mint))
}
kr, _, _ := gatherKeyerFromArguments(ctx, c)
results, err := nip61.SendNutzap(
ctx,
kr,
w,
sys.Pool,
pm.PubKey,
sys.FetchInboxRelays,
sys.FetchOutboxRelays(ctx, pm.PubKey, 3),
eventId,
amount,
c.String("message"),
)
if err != nil {
return err
}
log("- publishing nutzap... ")
first := true
for res := range results {
cleanUrl, ok := strings.CutPrefix(res.RelayURL, "wss://")
if !ok {
cleanUrl = res.RelayURL
}
if !first {
log(", ")
}
first = false
if res.Error != nil {
log("%s: %s", color.RedString(cleanUrl), res.Error)
} else {
log("%s: ok", color.GreenString(cleanUrl))
}
}
closew()
return nil
},
Commands: []*cli.Command{
{
Name: "setup",
Usage: "setup your wallet private key and kind:10019 event for receiving nutzaps",
DisableSliceFlagSeparator: true,
Flags: []cli.Flag{
&cli.StringSliceFlag{
Name: "mint",
Usage: "mints to receive nutzaps in",
},
&cli.StringFlag{
Name: "private-key",
Usage: "private key used for receiving nutzaps",
},
&cli.BoolFlag{
Name: "force",
Aliases: []string{"f"},
Usage: "forces replacement of private-key",
},
},
Action: func(ctx context.Context, c *cli.Command) error {
w, closew, err := prepareWallet(ctx, c)
if err != nil {
return err
}
if w.PrivateKey == nil {
if sk := c.String("private-key"); sk != "" {
if err := w.SetPrivateKey(ctx, sk); err != nil {
return err
}
} else {
return fmt.Errorf("missing --private-key")
}
} else if sk := c.String("private-key"); sk != "" && !c.Bool("force") {
return fmt.Errorf("refusing to replace existing private key, use the --force flag")
}
kr, _, _ := gatherKeyerFromArguments(ctx, c)
pk, _ := kr.GetPublicKey(ctx)
relays := sys.FetchWriteRelays(ctx, pk, 6)
info := nip61.Info{}
ie := sys.Pool.QuerySingle(ctx, relays, nostr.Filter{
Kinds: []int{10019},
Authors: []string{pk},
Limit: 1,
})
if ie != nil {
info.ParseEvent(ie.Event)
}
if mints := c.StringSlice("mints"); len(mints) == 0 && len(info.Mints) == 0 {
info.Mints = w.Mints
}
if len(info.Mints) == 0 {
return fmt.Errorf("missing --mint")
}
evt := nostr.Event{}
if err := info.ToEvent(ctx, kr, &evt); err != nil {
return err
}
stdout(evt)
log("- saving kind:10019 event... ")
first := true
for res := range sys.Pool.PublishMany(ctx, relays, evt) {
cleanUrl, ok := strings.CutPrefix(res.RelayURL, "wss://")
if !ok {
cleanUrl = res.RelayURL
}
if !first {
log(", ")
}
first = false
if res.Error != nil {
log("%s: %s", color.RedString(cleanUrl), res.Error)
} else {
log("%s: ok", color.GreenString(cleanUrl))
}
}
closew()
return nil
},
},
},
},
},
}

14
zapstore.yaml Normal file
View File

@@ -0,0 +1,14 @@
nak:
cli:
name: nak
summary: a command line tool for doing all things nostr
repository: https://github.com/fiatjaf/nak
artifacts:
nak-v%v-darwin-arm64:
platforms: [darwin-arm64]
nak-v%v-darwin-amd64:
platforms: [darwin-x86_64]
nak-v%v-linux-arm64:
platforms: [linux-aarch64]
nak-v%v-linux-amd64:
platforms: [linux-x86_64]