package nip46 import ( "context" "encoding/json" "fmt" "net/url" "strconv" "sync/atomic" "github.com/mailru/easyjson" "github.com/nbd-wtf/go-nostr" "github.com/nbd-wtf/go-nostr/nip04" "github.com/puzpuzpuz/xsync/v3" ) type BunkerClient struct { serial atomic.Uint64 clientSecretKey string pool *nostr.SimplePool target string relays []string sharedSecret []byte listeners *xsync.MapOf[string, chan Response] // memoized getPublicKeyResponse string } // ConnectBunker establishes an RPC connection to a NIP-46 signer using the relays and secret provided in the bunkerURL. // pool can be passed to reuse an existing pool, otherwise a new pool will be created. func ConnectBunker( ctx context.Context, clientSecretKey string, bunkerURL string, pool *nostr.SimplePool, ) (*BunkerClient, error) { parsed, err := url.Parse(bunkerURL) if err != nil { return nil, fmt.Errorf("invalid url: %w", err) } if parsed.Scheme != "bunker" { return nil, fmt.Errorf("wrong scheme '%s', must be bunker://", parsed.Scheme) } target := parsed.Host if !nostr.IsValidPublicKey(target) { return nil, fmt.Errorf("'%s' is not a valid public key hex", target) } secret := parsed.Query().Get("secret") relays := parsed.Query()["relay"] if pool == nil { pool = nostr.NewSimplePool(ctx) } shared, err := nip04.ComputeSharedSecret(target, clientSecretKey) if err != nil { return nil, fmt.Errorf("failed to compute shared secret: %w", err) } clientPubKey, _ := nostr.GetPublicKey(clientSecretKey) bunker := &BunkerClient{ clientSecretKey: clientSecretKey, pool: pool, target: target, relays: relays, sharedSecret: shared, listeners: xsync.NewMapOf[string, chan Response](), } go func() { events := pool.SubMany(ctx, relays, nostr.Filters{ { Tags: nostr.TagMap{"p": []string{clientPubKey}}, Kinds: []int{nostr.KindNostrConnect}, }, }) for ie := range events { if ie.Kind != nostr.KindNostrConnect { continue } var resp Response plain, err := nip04.Decrypt(ie.Content, shared) if err != nil { continue } err = json.Unmarshal([]byte(plain), &resp) if err != nil { continue } if dispatcher, ok := bunker.listeners.Load(resp.ID); ok { dispatcher <- resp } } }() ourPubkey, _ := nostr.GetPublicKey(clientSecretKey) _, err = bunker.RPC(ctx, "connect", []string{ourPubkey, secret}) return bunker, err } func (bunker *BunkerClient) Ping(ctx context.Context) error { _, err := bunker.RPC(ctx, "ping", []string{}) if err != nil { return err } return nil } func (bunker *BunkerClient) GetPublicKey(ctx context.Context) (string, error) { if bunker.getPublicKeyResponse != "" { return bunker.getPublicKeyResponse, nil } resp, err := bunker.RPC(ctx, "get_public_key", []string{}) bunker.getPublicKeyResponse = resp return resp, err } func (bunker *BunkerClient) SignEvent(ctx context.Context, evt *nostr.Event) error { resp, err := bunker.RPC(ctx, "sign_event", []string{evt.String()}) if err == nil { err = easyjson.Unmarshal([]byte(resp), evt) } return err } func (bunker *BunkerClient) RPC(ctx context.Context, method string, params []string) (string, error) { id := strconv.FormatUint(bunker.serial.Add(1), 10) req, err := json.Marshal(Request{ ID: id, Method: method, Params: params, }) if err != nil { return "", err } content, err := nip04.Encrypt(string(req), bunker.sharedSecret) if err != nil { return "", fmt.Errorf("error encrypting request: %w", err) } evt := nostr.Event{ Content: content, CreatedAt: nostr.Now(), Kind: nostr.KindNostrConnect, Tags: nostr.Tags{{"p", bunker.target}}, } if err := evt.Sign(bunker.clientSecretKey); err != nil { return "", fmt.Errorf("failed to sign request event: %w", err) } respWaiter := make(chan Response) bunker.listeners.Store(id, respWaiter) hasWorked := false for _, r := range bunker.relays { relay, err := bunker.pool.EnsureRelay(r) if err == nil { hasWorked = true } relay.Publish(ctx, evt) } if !hasWorked { return "", fmt.Errorf("couldn't connect to any relay") } resp := <-respWaiter if resp.Error != "" { return "", fmt.Errorf("response error: %s", resp.Error) } return resp.Result, nil }