Add Cashu blind signature access tokens (NIP-XX draft)
Implements privacy-preserving bearer tokens for relay access control using Cashu-style blind signatures. Tokens prove whitelist membership without linking issuance to usage. Features: - BDHKE crypto primitives (HashToCurve, Blind, Sign, Unblind, Verify) - Keyset management with weekly rotation - Token format with kind permissions and scope isolation - Generic issuer/verifier with pluggable authorization - HTTP endpoints: POST /cashu/mint, GET /cashu/keysets, GET /cashu/info - ACL adapter bridging ORLY's access control to Cashu AuthzChecker - Stateless revocation via ACL re-check on each token use - Two-token rotation for seamless renewal (max 2 weeks after blacklist) Configuration: - ORLY_CASHU_ENABLED: Enable Cashu tokens - ORLY_CASHU_TOKEN_TTL: Token validity (default: 1 week) - ORLY_CASHU_SCOPES: Allowed scopes (relay, nip46, blossom, api) - ORLY_CASHU_REAUTHORIZE: Re-check ACL on each verification Files: - pkg/cashu/bdhke/: Core blind signature cryptography - pkg/cashu/keyset/: Keyset management and rotation - pkg/cashu/token/: Token format with kind permissions - pkg/cashu/issuer/: Token issuance with authorization - pkg/cashu/verifier/: Token verification with middleware - pkg/interfaces/cashu/: AuthzChecker, KeysetStore interfaces - pkg/bunker/acl_adapter.go: ORLY ACL integration - app/handle-cashu.go: HTTP endpoints - docs/NIP-XX-CASHU-ACCESS-TOKENS.md: Full specification 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
288
pkg/cashu/issuer/issuer.go
Normal file
288
pkg/cashu/issuer/issuer.go
Normal file
@@ -0,0 +1,288 @@
|
||||
// Package issuer implements Cashu token issuance with authorization checks.
|
||||
package issuer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/decred/dcrd/dcrec/secp256k1/v4"
|
||||
|
||||
"next.orly.dev/pkg/cashu/bdhke"
|
||||
"next.orly.dev/pkg/cashu/keyset"
|
||||
"next.orly.dev/pkg/cashu/token"
|
||||
cashuiface "next.orly.dev/pkg/interfaces/cashu"
|
||||
)
|
||||
|
||||
// Errors.
|
||||
var (
|
||||
ErrNoActiveKeyset = errors.New("issuer: no active keyset available")
|
||||
ErrInvalidBlindedMsg = errors.New("issuer: invalid blinded message")
|
||||
ErrInvalidPubkey = errors.New("issuer: invalid pubkey")
|
||||
ErrInvalidScope = errors.New("issuer: invalid scope")
|
||||
)
|
||||
|
||||
// Config holds issuer configuration.
|
||||
type Config struct {
|
||||
// DefaultTTL is the default token lifetime.
|
||||
DefaultTTL time.Duration
|
||||
|
||||
// MaxTTL is the maximum allowed token lifetime.
|
||||
MaxTTL time.Duration
|
||||
|
||||
// AllowedScopes is the list of scopes this issuer can issue tokens for.
|
||||
// Empty means all scopes are allowed.
|
||||
AllowedScopes []string
|
||||
|
||||
// MaxKinds is the maximum number of explicit kinds in a token.
|
||||
// 0 means unlimited.
|
||||
MaxKinds int
|
||||
|
||||
// MaxKindRanges is the maximum number of kind ranges in a token.
|
||||
// 0 means unlimited.
|
||||
MaxKindRanges int
|
||||
}
|
||||
|
||||
// DefaultConfig returns sensible default configuration.
|
||||
func DefaultConfig() Config {
|
||||
return Config{
|
||||
DefaultTTL: 7 * 24 * time.Hour, // 1 week
|
||||
MaxTTL: 7 * 24 * time.Hour, // 1 week
|
||||
MaxKinds: 100,
|
||||
MaxKindRanges: 10,
|
||||
}
|
||||
}
|
||||
|
||||
// Issuer handles token issuance with authorization checks.
|
||||
type Issuer struct {
|
||||
keysets *keyset.Manager
|
||||
authz cashuiface.AuthzChecker
|
||||
config Config
|
||||
}
|
||||
|
||||
// New creates a new issuer.
|
||||
func New(keysets *keyset.Manager, authz cashuiface.AuthzChecker, config Config) *Issuer {
|
||||
return &Issuer{
|
||||
keysets: keysets,
|
||||
authz: authz,
|
||||
config: config,
|
||||
}
|
||||
}
|
||||
|
||||
// IssueRequest contains the request parameters for token issuance.
|
||||
type IssueRequest struct {
|
||||
// BlindedMessage is the blinded point B_ (33 bytes compressed).
|
||||
BlindedMessage []byte
|
||||
|
||||
// Pubkey is the user's Nostr pubkey (32 bytes).
|
||||
Pubkey []byte
|
||||
|
||||
// Scope is the requested token scope.
|
||||
Scope string
|
||||
|
||||
// Kinds is the list of permitted event kinds.
|
||||
Kinds []int
|
||||
|
||||
// KindRanges is the list of permitted kind ranges.
|
||||
KindRanges [][]int
|
||||
|
||||
// TTL is the requested token lifetime (optional, uses default if zero).
|
||||
TTL time.Duration
|
||||
}
|
||||
|
||||
// IssueResponse contains the response from token issuance.
|
||||
type IssueResponse struct {
|
||||
// BlindedSignature is the blinded signature C_ (33 bytes compressed).
|
||||
BlindedSignature []byte
|
||||
|
||||
// KeysetID is the ID of the keyset used for signing.
|
||||
KeysetID string
|
||||
|
||||
// Expiry is the token expiration timestamp.
|
||||
Expiry int64
|
||||
|
||||
// MintPubkey is the public key of the keyset (for unblinding).
|
||||
MintPubkey []byte
|
||||
}
|
||||
|
||||
// Issue creates a blinded signature after authorization check.
|
||||
func (i *Issuer) Issue(ctx context.Context, req *IssueRequest, remoteAddr string) (*IssueResponse, error) {
|
||||
// Validate request
|
||||
if err := i.validateRequest(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check authorization
|
||||
if err := i.authz.CheckAuthorization(ctx, req.Pubkey, req.Scope, remoteAddr); err != nil {
|
||||
return nil, fmt.Errorf("issuer: authorization failed: %w", err)
|
||||
}
|
||||
|
||||
// Get active keyset
|
||||
ks := i.keysets.GetSigningKeyset()
|
||||
if ks == nil || !ks.IsActiveForSigning() {
|
||||
return nil, ErrNoActiveKeyset
|
||||
}
|
||||
|
||||
// Parse blinded message
|
||||
B_, err := secp256k1.ParsePubKey(req.BlindedMessage)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrInvalidBlindedMsg, err)
|
||||
}
|
||||
|
||||
// Sign the blinded message
|
||||
C_, err := bdhke.Sign(B_, ks.PrivateKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("issuer: signing failed: %w", err)
|
||||
}
|
||||
|
||||
// Calculate expiry
|
||||
ttl := req.TTL
|
||||
if ttl <= 0 {
|
||||
ttl = i.config.DefaultTTL
|
||||
}
|
||||
if ttl > i.config.MaxTTL {
|
||||
ttl = i.config.MaxTTL
|
||||
}
|
||||
expiry := time.Now().Add(ttl).Unix()
|
||||
|
||||
return &IssueResponse{
|
||||
BlindedSignature: C_.SerializeCompressed(),
|
||||
KeysetID: ks.ID,
|
||||
Expiry: expiry,
|
||||
MintPubkey: ks.SerializePublicKey(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// validateRequest validates the issue request.
|
||||
func (i *Issuer) validateRequest(req *IssueRequest) error {
|
||||
// Validate blinded message
|
||||
if len(req.BlindedMessage) != 33 {
|
||||
return fmt.Errorf("%w: expected 33 bytes, got %d", ErrInvalidBlindedMsg, len(req.BlindedMessage))
|
||||
}
|
||||
|
||||
// Validate pubkey
|
||||
if len(req.Pubkey) != 32 {
|
||||
return fmt.Errorf("%w: expected 32 bytes, got %d", ErrInvalidPubkey, len(req.Pubkey))
|
||||
}
|
||||
|
||||
// Validate scope
|
||||
if req.Scope == "" {
|
||||
return ErrInvalidScope
|
||||
}
|
||||
if len(i.config.AllowedScopes) > 0 {
|
||||
allowed := false
|
||||
for _, s := range i.config.AllowedScopes {
|
||||
if s == req.Scope {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !allowed {
|
||||
return fmt.Errorf("%w: %s not in allowed scopes", ErrInvalidScope, req.Scope)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate kinds count
|
||||
if i.config.MaxKinds > 0 && len(req.Kinds) > i.config.MaxKinds {
|
||||
return fmt.Errorf("issuer: too many kinds: %d > %d", len(req.Kinds), i.config.MaxKinds)
|
||||
}
|
||||
|
||||
// Validate kind ranges count
|
||||
if i.config.MaxKindRanges > 0 && len(req.KindRanges) > i.config.MaxKindRanges {
|
||||
return fmt.Errorf("issuer: too many kind ranges: %d > %d", len(req.KindRanges), i.config.MaxKindRanges)
|
||||
}
|
||||
|
||||
// Validate kind ranges format
|
||||
for idx, r := range req.KindRanges {
|
||||
if len(r) != 2 {
|
||||
return fmt.Errorf("issuer: kind range %d must have 2 elements", idx)
|
||||
}
|
||||
if r[0] > r[1] {
|
||||
return fmt.Errorf("issuer: kind range %d min > max: %d > %d", idx, r[0], r[1])
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetKeysetInfo returns public information about available keysets.
|
||||
func (i *Issuer) GetKeysetInfo() []keyset.KeysetInfo {
|
||||
return i.keysets.ListKeysetInfo()
|
||||
}
|
||||
|
||||
// GetActiveKeysetID returns the ID of the currently active keyset.
|
||||
func (i *Issuer) GetActiveKeysetID() string {
|
||||
ks := i.keysets.GetSigningKeyset()
|
||||
if ks == nil {
|
||||
return ""
|
||||
}
|
||||
return ks.ID
|
||||
}
|
||||
|
||||
// MintInfo contains public information about the mint.
|
||||
type MintInfo struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Version string `json:"version"`
|
||||
TokenTTL int64 `json:"token_ttl"`
|
||||
MaxKinds int `json:"max_kinds,omitempty"`
|
||||
MaxKindRanges int `json:"max_kind_ranges,omitempty"`
|
||||
SupportedScopes []string `json:"supported_scopes,omitempty"`
|
||||
}
|
||||
|
||||
// GetMintInfo returns public information about the issuer.
|
||||
func (i *Issuer) GetMintInfo(name string) MintInfo {
|
||||
return MintInfo{
|
||||
Name: name,
|
||||
Version: "NIP-XX/1",
|
||||
TokenTTL: int64(i.config.DefaultTTL.Seconds()),
|
||||
MaxKinds: i.config.MaxKinds,
|
||||
MaxKindRanges: i.config.MaxKindRanges,
|
||||
SupportedScopes: i.config.AllowedScopes,
|
||||
}
|
||||
}
|
||||
|
||||
// BuildToken is a helper that creates a complete token from the issue response
|
||||
// and the user's secret and blinding factor.
|
||||
// This is typically done client-side, but provided for testing and CLI tools.
|
||||
func BuildToken(
|
||||
resp *IssueResponse,
|
||||
secret []byte,
|
||||
blindingFactor *secp256k1.PrivateKey,
|
||||
pubkey []byte,
|
||||
scope string,
|
||||
kinds []int,
|
||||
kindRanges [][]int,
|
||||
) (*token.Token, error) {
|
||||
// Parse mint pubkey
|
||||
mintPubkey, err := secp256k1.ParsePubKey(resp.MintPubkey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid mint pubkey: %w", err)
|
||||
}
|
||||
|
||||
// Parse blinded signature
|
||||
C_, err := secp256k1.ParsePubKey(resp.BlindedSignature)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid blinded signature: %w", err)
|
||||
}
|
||||
|
||||
// Unblind the signature
|
||||
C, err := bdhke.Unblind(C_, blindingFactor, mintPubkey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unblind failed: %w", err)
|
||||
}
|
||||
|
||||
// Create token
|
||||
tok := token.New(
|
||||
resp.KeysetID,
|
||||
secret,
|
||||
C.SerializeCompressed(),
|
||||
pubkey,
|
||||
time.Unix(resp.Expiry, 0),
|
||||
scope,
|
||||
)
|
||||
tok.SetKinds(kinds...)
|
||||
tok.KindRanges = kindRanges
|
||||
|
||||
return tok, nil
|
||||
}
|
||||
296
pkg/cashu/issuer/issuer_test.go
Normal file
296
pkg/cashu/issuer/issuer_test.go
Normal file
@@ -0,0 +1,296 @@
|
||||
package issuer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/decred/dcrd/dcrec/secp256k1/v4"
|
||||
|
||||
"next.orly.dev/pkg/cashu/bdhke"
|
||||
"next.orly.dev/pkg/cashu/keyset"
|
||||
"next.orly.dev/pkg/cashu/token"
|
||||
cashuiface "next.orly.dev/pkg/interfaces/cashu"
|
||||
)
|
||||
|
||||
func setupIssuer(authz cashuiface.AuthzChecker) (*Issuer, *keyset.Manager) {
|
||||
store := keyset.NewMemoryStore()
|
||||
manager := keyset.NewManager(store, keyset.DefaultActiveWindow, keyset.DefaultVerifyWindow)
|
||||
manager.Init()
|
||||
|
||||
config := DefaultConfig()
|
||||
issuer := New(manager, authz, config)
|
||||
|
||||
return issuer, manager
|
||||
}
|
||||
|
||||
func TestIssueSuccess(t *testing.T) {
|
||||
issuer, _ := setupIssuer(cashuiface.AllowAllChecker{})
|
||||
|
||||
// Generate user keypair
|
||||
secret, err := bdhke.GenerateSecret()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateSecret failed: %v", err)
|
||||
}
|
||||
|
||||
// Generate blinded message
|
||||
blindResult, err := bdhke.Blind(secret)
|
||||
if err != nil {
|
||||
t.Fatalf("Blind failed: %v", err)
|
||||
}
|
||||
|
||||
// User pubkey
|
||||
pubkey := make([]byte, 32)
|
||||
for i := range pubkey {
|
||||
pubkey[i] = byte(i)
|
||||
}
|
||||
|
||||
req := &IssueRequest{
|
||||
BlindedMessage: blindResult.B.SerializeCompressed(),
|
||||
Pubkey: pubkey,
|
||||
Scope: token.ScopeRelay,
|
||||
Kinds: []int{0, 1, 3},
|
||||
KindRanges: [][]int{{30000, 39999}},
|
||||
}
|
||||
|
||||
resp, err := issuer.Issue(context.Background(), req, "127.0.0.1")
|
||||
if err != nil {
|
||||
t.Fatalf("Issue failed: %v", err)
|
||||
}
|
||||
|
||||
// Check response
|
||||
if len(resp.BlindedSignature) != 33 {
|
||||
t.Errorf("BlindedSignature length = %d, want 33", len(resp.BlindedSignature))
|
||||
}
|
||||
if resp.KeysetID == "" {
|
||||
t.Error("KeysetID is empty")
|
||||
}
|
||||
if resp.Expiry <= time.Now().Unix() {
|
||||
t.Error("Expiry should be in the future")
|
||||
}
|
||||
if len(resp.MintPubkey) != 33 {
|
||||
t.Errorf("MintPubkey length = %d, want 33", len(resp.MintPubkey))
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueAuthorizationDenied(t *testing.T) {
|
||||
issuer, _ := setupIssuer(cashuiface.DenyAllChecker{})
|
||||
|
||||
secret, _ := bdhke.GenerateSecret()
|
||||
blindResult, _ := bdhke.Blind(secret)
|
||||
pubkey := make([]byte, 32)
|
||||
|
||||
req := &IssueRequest{
|
||||
BlindedMessage: blindResult.B.SerializeCompressed(),
|
||||
Pubkey: pubkey,
|
||||
Scope: token.ScopeRelay,
|
||||
}
|
||||
|
||||
_, err := issuer.Issue(context.Background(), req, "127.0.0.1")
|
||||
if err == nil {
|
||||
t.Error("Issue should fail when authorization is denied")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueInvalidBlindedMessage(t *testing.T) {
|
||||
issuer, _ := setupIssuer(cashuiface.AllowAllChecker{})
|
||||
|
||||
pubkey := make([]byte, 32)
|
||||
|
||||
req := &IssueRequest{
|
||||
BlindedMessage: []byte{1, 2, 3}, // Invalid
|
||||
Pubkey: pubkey,
|
||||
Scope: token.ScopeRelay,
|
||||
}
|
||||
|
||||
_, err := issuer.Issue(context.Background(), req, "127.0.0.1")
|
||||
if err == nil {
|
||||
t.Error("Issue should fail with invalid blinded message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueInvalidPubkey(t *testing.T) {
|
||||
issuer, _ := setupIssuer(cashuiface.AllowAllChecker{})
|
||||
|
||||
secret, _ := bdhke.GenerateSecret()
|
||||
blindResult, _ := bdhke.Blind(secret)
|
||||
|
||||
req := &IssueRequest{
|
||||
BlindedMessage: blindResult.B.SerializeCompressed(),
|
||||
Pubkey: []byte{1, 2, 3}, // Invalid length
|
||||
Scope: token.ScopeRelay,
|
||||
}
|
||||
|
||||
_, err := issuer.Issue(context.Background(), req, "127.0.0.1")
|
||||
if err == nil {
|
||||
t.Error("Issue should fail with invalid pubkey")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueInvalidScope(t *testing.T) {
|
||||
store := keyset.NewMemoryStore()
|
||||
manager := keyset.NewManager(store, keyset.DefaultActiveWindow, keyset.DefaultVerifyWindow)
|
||||
manager.Init()
|
||||
|
||||
config := DefaultConfig()
|
||||
config.AllowedScopes = []string{token.ScopeRelay} // Only relay scope allowed
|
||||
|
||||
issuer := New(manager, cashuiface.AllowAllChecker{}, config)
|
||||
|
||||
secret, _ := bdhke.GenerateSecret()
|
||||
blindResult, _ := bdhke.Blind(secret)
|
||||
pubkey := make([]byte, 32)
|
||||
|
||||
req := &IssueRequest{
|
||||
BlindedMessage: blindResult.B.SerializeCompressed(),
|
||||
Pubkey: pubkey,
|
||||
Scope: token.ScopeNIP46, // Not allowed
|
||||
}
|
||||
|
||||
_, err := issuer.Issue(context.Background(), req, "127.0.0.1")
|
||||
if err == nil {
|
||||
t.Error("Issue should fail with disallowed scope")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueTTL(t *testing.T) {
|
||||
issuer, _ := setupIssuer(cashuiface.AllowAllChecker{})
|
||||
|
||||
secret, _ := bdhke.GenerateSecret()
|
||||
blindResult, _ := bdhke.Blind(secret)
|
||||
pubkey := make([]byte, 32)
|
||||
|
||||
// Request with custom TTL
|
||||
req := &IssueRequest{
|
||||
BlindedMessage: blindResult.B.SerializeCompressed(),
|
||||
Pubkey: pubkey,
|
||||
Scope: token.ScopeRelay,
|
||||
TTL: time.Hour,
|
||||
}
|
||||
|
||||
resp, err := issuer.Issue(context.Background(), req, "127.0.0.1")
|
||||
if err != nil {
|
||||
t.Fatalf("Issue failed: %v", err)
|
||||
}
|
||||
|
||||
// Expiry should be ~1 hour from now
|
||||
expectedExpiry := time.Now().Add(time.Hour).Unix()
|
||||
if resp.Expiry < expectedExpiry-60 || resp.Expiry > expectedExpiry+60 {
|
||||
t.Errorf("Expiry %d not within expected range of %d", resp.Expiry, expectedExpiry)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildToken(t *testing.T) {
|
||||
issuer, manager := setupIssuer(cashuiface.AllowAllChecker{})
|
||||
|
||||
// Generate secret and blind it
|
||||
secret, _ := bdhke.GenerateSecret()
|
||||
blindResult, _ := bdhke.Blind(secret)
|
||||
pubkey := make([]byte, 32)
|
||||
for i := range pubkey {
|
||||
pubkey[i] = byte(i)
|
||||
}
|
||||
|
||||
// Issue token
|
||||
req := &IssueRequest{
|
||||
BlindedMessage: blindResult.B.SerializeCompressed(),
|
||||
Pubkey: pubkey,
|
||||
Scope: token.ScopeRelay,
|
||||
Kinds: []int{1, 2, 3},
|
||||
}
|
||||
|
||||
resp, err := issuer.Issue(context.Background(), req, "127.0.0.1")
|
||||
if err != nil {
|
||||
t.Fatalf("Issue failed: %v", err)
|
||||
}
|
||||
|
||||
// Build complete token
|
||||
tok, err := BuildToken(resp, secret, blindResult.R, pubkey, token.ScopeRelay, []int{1, 2, 3}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildToken failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify token structure
|
||||
if tok.KeysetID != resp.KeysetID {
|
||||
t.Errorf("KeysetID mismatch: %s != %s", tok.KeysetID, resp.KeysetID)
|
||||
}
|
||||
if tok.Scope != token.ScopeRelay {
|
||||
t.Errorf("Scope = %s, want %s", tok.Scope, token.ScopeRelay)
|
||||
}
|
||||
|
||||
// Verify signature (using the keyset)
|
||||
ks := manager.FindByID(tok.KeysetID)
|
||||
if ks == nil {
|
||||
t.Fatal("Keyset not found")
|
||||
}
|
||||
|
||||
valid, err := bdhke.Verify(tok.Secret, mustParsePoint(tok.Signature), ks.PrivateKey)
|
||||
if err != nil {
|
||||
t.Fatalf("Verify failed: %v", err)
|
||||
}
|
||||
if !valid {
|
||||
t.Error("Token signature is not valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetKeysetInfo(t *testing.T) {
|
||||
issuer, _ := setupIssuer(cashuiface.AllowAllChecker{})
|
||||
|
||||
infos := issuer.GetKeysetInfo()
|
||||
if len(infos) == 0 {
|
||||
t.Error("GetKeysetInfo returned empty")
|
||||
}
|
||||
|
||||
for _, info := range infos {
|
||||
if info.ID == "" {
|
||||
t.Error("KeysetInfo has empty ID")
|
||||
}
|
||||
if info.PublicKey == "" {
|
||||
t.Error("KeysetInfo has empty PublicKey")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetActiveKeysetID(t *testing.T) {
|
||||
issuer, _ := setupIssuer(cashuiface.AllowAllChecker{})
|
||||
|
||||
id := issuer.GetActiveKeysetID()
|
||||
if id == "" {
|
||||
t.Error("GetActiveKeysetID returned empty")
|
||||
}
|
||||
if len(id) != 14 {
|
||||
t.Errorf("KeysetID length = %d, want 14", len(id))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMintInfo(t *testing.T) {
|
||||
store := keyset.NewMemoryStore()
|
||||
manager := keyset.NewManager(store, keyset.DefaultActiveWindow, keyset.DefaultVerifyWindow)
|
||||
manager.Init()
|
||||
|
||||
config := DefaultConfig()
|
||||
config.AllowedScopes = []string{token.ScopeRelay, token.ScopeNIP46}
|
||||
|
||||
issuer := New(manager, cashuiface.AllowAllChecker{}, config)
|
||||
|
||||
info := issuer.GetMintInfo("Test Relay")
|
||||
|
||||
if info.Name != "Test Relay" {
|
||||
t.Errorf("Name = %s, want Test Relay", info.Name)
|
||||
}
|
||||
if info.Version != "NIP-XX/1" {
|
||||
t.Errorf("Version = %s, want NIP-XX/1", info.Version)
|
||||
}
|
||||
if len(info.SupportedScopes) != 2 {
|
||||
t.Errorf("SupportedScopes length = %d, want 2", len(info.SupportedScopes))
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to parse point for testing
|
||||
func mustParsePoint(data []byte) *secp256k1.PublicKey {
|
||||
pk, err := secp256k1.ParsePubKey(data)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return pk
|
||||
}
|
||||
Reference in New Issue
Block a user