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:   &timestamp.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