main.go raw
1 // test-subscribe-e2e exercises the full Marmot bridge subscribe flow:
2 // send "subscribe" DM → receive invoice → pay via NWC → receive confirmation.
3 package main
4
5 import (
6 "context"
7 "fmt"
8 "os"
9 "strings"
10 "time"
11
12 "next.orly.dev/pkg/nostr/crypto/encryption"
13 "next.orly.dev/pkg/nostr/crypto/keys"
14 "next.orly.dev/pkg/nostr/encoders/event"
15 "next.orly.dev/pkg/nostr/encoders/filter"
16 "next.orly.dev/pkg/nostr/encoders/hex"
17 "next.orly.dev/pkg/nostr/encoders/kind"
18 "next.orly.dev/pkg/nostr/encoders/tag"
19 "next.orly.dev/pkg/nostr/encoders/timestamp"
20 "next.orly.dev/pkg/nostr/ws"
21 "next.orly.dev/pkg/lol/log"
22
23 "next.orly.dev/pkg/protocol/nwc"
24 )
25
26 func main() {
27 relayURL := envOr("RELAY_URL", "wss://relay.orly.dev")
28 bridgePubHex := envOr("BRIDGE_PUBKEY", "cf1ae33ad5f229dabd7d733ce37b0165126aebf581e4094df9373f77e00cb696")
29 nwcURI := os.Getenv("ORLY_NWC_URI")
30
31 if nwcURI == "" {
32 fatal("ORLY_NWC_URI must be set")
33 }
34
35 // Generate or load keypair
36 var secretBytes []byte
37 var err error
38 if sk := os.Getenv("NOSTR_SECRET_KEY"); sk != "" {
39 secretBytes, err = hex.Dec(sk)
40 if err != nil {
41 fatal("decode secret key: %v", err)
42 }
43 } else {
44 secretBytes, err = keys.GenerateSecretKey()
45 if err != nil {
46 fatal("generate key: %v", err)
47 }
48 }
49 signer, err := keys.SecretBytesToSigner(secretBytes)
50 if err != nil {
51 fatal("create signer: %v", err)
52 }
53 defer signer.Zero()
54
55 myPubHex := hex.Enc(signer.Pub())
56 fmt.Printf("client pubkey: %s\n", myPubHex)
57
58 // Derive conversation key with bridge for decryption
59 bridgePub, err := hex.Dec(bridgePubHex)
60 if err != nil {
61 fatal("decode bridge pubkey: %v", err)
62 }
63 convKey, err := encryption.GenerateConversationKey(signer.Sec(), bridgePub)
64 if err != nil {
65 fatal("conversation key: %v", err)
66 }
67
68 ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
69 defer cancel()
70
71 // Connect to relay
72 fmt.Printf("connecting to %s...\n", relayURL)
73 conn, err := ws.RelayConnect(ctx, relayURL)
74 if err != nil {
75 fatal("relay connect: %v", err)
76 }
77 defer conn.Close()
78
79 // Subscribe for DMs from the bridge to us
80 since := time.Now().Unix() - 5
81 sub, err := conn.Subscribe(ctx, filter.NewS(
82 &filter.F{
83 Kinds: kind.NewS(kind.New(4)),
84 Authors: tag.NewFromAny(bridgePub),
85 Tags: tag.NewS(tag.NewFromAny("p", myPubHex)),
86 Since: ×tamp.T{V: since},
87 },
88 ))
89 if err != nil {
90 fatal("subscribe: %v", err)
91 }
92 defer sub.Unsub()
93 fmt.Println("subscribed for bridge DMs")
94
95 // Send "subscribe" DM to bridge
96 fmt.Println("sending 'subscribe' DM...")
97 plaintext := []byte("subscribe")
98 ciphertext, err := encryption.Encrypt(convKey, plaintext, nil)
99 if err != nil {
100 fatal("encrypt: %v", err)
101 }
102
103 ev := &event.E{
104 Content: []byte(ciphertext),
105 CreatedAt: time.Now().Unix(),
106 Kind: 4,
107 Tags: tag.NewS(tag.NewFromAny("p", bridgePubHex)),
108 }
109 if err := ev.Sign(signer); err != nil {
110 fatal("sign: %v", err)
111 }
112 if err := conn.Publish(ctx, ev); err != nil {
113 fatal("publish: %v", err)
114 }
115 fmt.Printf("subscribe DM published (event %s)\n", hex.Enc(ev.ID))
116
117 // Wait for invoice DM from bridge
118 fmt.Println("waiting for invoice DM...")
119 bolt11 := ""
120 for bolt11 == "" {
121 select {
122 case <-ctx.Done():
123 fatal("timeout waiting for invoice DM")
124 case reason := <-sub.ClosedReason:
125 fatal("subscription closed: %s", reason)
126 case e := <-sub.Events:
127 if e == nil {
128 fatal("subscription channel closed")
129 }
130 decrypted, err := encryption.Decrypt(convKey, string(e.Content))
131 if err != nil {
132 fmt.Printf(" (could not decrypt DM: %v)\n", err)
133 continue
134 }
135 fmt.Printf(" bridge DM: %s\n", truncate(decrypted, 200))
136
137 // Extract bolt11 from the DM text
138 bolt11 = extractBolt11(decrypted)
139 if bolt11 == "" {
140 fmt.Println(" (no bolt11 found in this DM, waiting for more...)")
141 }
142 }
143 }
144
145 fmt.Printf("extracted bolt11: %s...\n", truncate(bolt11, 40))
146
147 // Pay the invoice via NWC
148 fmt.Println("paying invoice via NWC...")
149 nwcClient, err := nwc.NewClient(nwcURI)
150 if err != nil {
151 fatal("NWC client: %v", err)
152 }
153
154 var payResult map[string]any
155 err = nwcClient.Request(ctx, "pay_invoice", map[string]any{
156 "invoice": bolt11,
157 }, &payResult)
158 if err != nil {
159 fatal("pay_invoice: %v", err)
160 }
161 fmt.Printf("payment sent: %v\n", payResult)
162
163 // Wait for confirmation DM from bridge
164 fmt.Println("waiting for confirmation DM...")
165 confirmed := false
166 deadline := time.After(2 * time.Minute)
167 for !confirmed {
168 select {
169 case <-deadline:
170 fmt.Println("WARNING: timeout waiting for confirmation DM (payment may still be processing)")
171 confirmed = true // exit loop
172 case <-ctx.Done():
173 fatal("context cancelled waiting for confirmation")
174 case reason := <-sub.ClosedReason:
175 fatal("subscription closed: %s", reason)
176 case e := <-sub.Events:
177 if e == nil {
178 fatal("subscription channel closed")
179 }
180 decrypted, err := encryption.Decrypt(convKey, string(e.Content))
181 if err != nil {
182 fmt.Printf(" (could not decrypt DM: %v)\n", err)
183 continue
184 }
185 fmt.Printf(" bridge DM: %s\n", truncate(decrypted, 300))
186 if strings.Contains(decrypted, "active") || strings.Contains(decrypted, "Payment received") {
187 confirmed = true
188 fmt.Println("\n=== PASS: Full subscribe flow completed ===")
189 }
190 }
191 }
192 }
193
194 func extractBolt11(text string) string {
195 // Look for lnbc... string (Lightning invoice)
196 lower := strings.ToLower(text)
197 idx := strings.Index(lower, "lnbc")
198 if idx < 0 {
199 return ""
200 }
201 // Extract the bolt11 string (ends at whitespace or end of string)
202 rest := text[idx:]
203 end := strings.IndexAny(rest, " \n\r\t")
204 if end < 0 {
205 return rest
206 }
207 return rest[:end]
208 }
209
210 func truncate(s string, n int) string {
211 if len(s) <= n {
212 return s
213 }
214 return s[:n] + "..."
215 }
216
217 func envOr(key, fallback string) string {
218 if v := os.Getenv(key); v != "" {
219 return v
220 }
221 return fallback
222 }
223
224 func fatal(format string, args ...any) {
225 log.F.F(format, args...)
226 }
227