Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,38 @@

## [Unreleased]

### Added

- `nipcash.ResolvedConnectionKey`: builds a `connection_key`-mode Recipient/
Target from a `nipIC.ConnectionKey` the caller already has (e.g. decoded
from an `nconnection1...` string via `nipIC.DecodeNConnection`), without
re-hashing it from a raw external ID the caller may not have on hand.
`nipcash.ConnectionKey` still hashes `(platform, externalID)` internally
for the common case; this is the counterpart for a caller starting from
the key itself.
- `nipcash.SplitBearerSliceString`: splits a bearer slice's combined
`"<token>#<bearer_secret>"` presentation into its two parts.
- `nipcash.CheckClaim` / `nipcash/client.CheckClaim`: one call to check
whether a token has a real, unclaimed recipient (bearer or pubkey).
- `nipcash.IsPubkeyTarget`: reports whether a `Target` is pubkey-identified.
- `nipcash/client.RekeyBearerSlice`: re-keys a bearer slice under a fresh
secret, optionally merging it with other same-issuer sources.
- `nipcash/client.TransferFromSources`: transfers an amount drawn from one
or more sources, auto-consolidating first if none alone covers it.
- `nipcash/client.PartialProgressError`: reports partial progress when the
first of two chained calls above lands but the second fails.

### Changed

- `cash_consolidate` now accepts a bearer or connection_key `To` target,
not just pubkey. `ErrConsolidateTargetNotPubkey` renamed to
`ErrConsolidateTargetInvalid`.

### Fixed

- `TransferFromSources` reused a stale, wallet-bound client for its
second call, so every multi-source transfer failed with `NOT_FOUND`.
Now reconnects to the new wallet first.
- `nip47.GetInfoResult`/`PayInvoiceResult`/`PayKeysendResult`/`Transaction`
had no field for a circle_hub's own `get_info` terms block or a
circle_wallet payment's forwarding-fee skim — `encoding/json` silently
Expand Down
83 changes: 83 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -535,6 +535,89 @@ func main() {
}
```

### Mint, verify, and secure a bearer-mode cash slice (NIP-CASH)

A **bearer-mode** slice (`nipcash.Anyone()` as the recipient) has no
Nostr identity attached — whoever holds its `bearer_secret` can spend it.
`CheckClaim` confirms a received slice is real before trusting it;
`RekeyBearerSlice` then re-keys it under a fresh secret only the new
holder knows, so the old one stops working:

```go
package main

import (
"context"
"errors"
"fmt"
"time"

"github.com/ohstr/nmilat/nipcash"
cashclient "github.com/ohstr/nmilat/nipcash/client"
)

func main() {
ctx := context.Background()

hub, err := cashclient.Connect(ctx, cashHubPairingURI)
if err != nil {
panic(err)
}
defer hub.Close()

minted, err := hub.MintCash(ctx, nipcash.MintCashParams{
Recipients: []nipcash.Allocation{nipcash.Send(nipcash.Anyone(), 21_000_000)},
Expiry: 24 * time.Hour,
})
if err != nil {
panic(err)
}

// One string to hand over: the combined "<token>#<bearer_secret>"
// presentation.
billString := minted.CashToken + "#" + minted.Recipients[0].BearerSecret

// Recipient's side: split it, then verify it's real.
token, secret := nipcash.SplitBearerSliceString(billString)
tok, err := nipcash.Decode(token)
if err != nil {
panic(err)
}

recipient, err := cashclient.Connect(ctx, token)
if err != nil {
panic(err)
}
defer recipient.Close()

check, err := recipient.CheckClaim(ctx, tok, nipcash.NoLocalIdentity)
if errors.Is(err, nipcash.ErrClaimNotFound) {
panic("dead, already-claimed, or never a real slice")
} else if err != nil {
panic(err)
}
fmt.Println("verified:", check.AmountMillis, "millis, bearer:", check.IsBearer)

// Re-key it: secured.NewSecret is the only copy, persist it now.
secured, err := recipient.RekeyBearerSlice(ctx, cashclient.RekeyBearerSliceParams{
BearerSlice: nipcash.Source{
WalletPubkey: tok.WalletPubkey,
Amount: check.AmountMillis,
Credential: nipcash.BySecret(secret),
},
})
if err != nil {
panic(err)
}
fmt.Println("secured — new secret only this wallet knows:", secured.NewSecret)
}
```

To merge with another same-issuer slice you already hold, add
`ConsolidateWith`, `InterimIdentity`, and `InterimCredential` to the same
call. `nipcash/client.TransferFromSources` does the reverse: send a
specific amount, drawing from and auto-consolidating several sources.

### Upload a blob to a Blossom server (NIP-B7)

Build a BUD-11 Authorization token scoped to the `upload` verb, then hand it
Expand Down
22 changes: 22 additions & 0 deletions nipcash/bearer_string.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package nipcash

import "strings"

// SplitBearerSliceString splits a bearer slice's optional "<token>#<bearer_secret>"
// combined presentation (§Presenting a Bearer Slice as One String) back
// into its two parts. "#" never appears in a bech32 charset, so splitting
// on the first one is always unambiguous — a display-layer convenience
// only, not a new wire format. A string with no "#" at all (every
// identity-bound token, or a bearer token whose sender chose to hand the
// two values over separately — still explicitly allowed by the spec)
// returns unchanged, with an empty secret.
//
// Every caller MUST use the returned token — never the original combined
// string — in any error message, --json "input" field, or other output:
// the whole point of accepting this format is convenience, not making it
// easier to accidentally echo a spending credential back to a terminal,
// log, or JSON response.
func SplitBearerSliceString(s string) (token, bearerSecret string) {
token, bearerSecret, _ = strings.Cut(s, "#")
return token, bearerSecret
}
25 changes: 25 additions & 0 deletions nipcash/bearer_string_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package nipcash

import "testing"

func TestSplitBearerSliceString(t *testing.T) {
tests := []struct {
name string
in string
wantToken string
wantSecret string
}{
{"no hash: identity-bound or separately-presented token", "lokicash1abc", "lokicash1abc", ""},
{"combined presentation: token and secret", "lokicash1abc#deadbeef", "lokicash1abc", "deadbeef"},
{"only the first hash matters", "lokicash1abc#dead#beef", "lokicash1abc", "dead#beef"},
{"empty string", "", "", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotToken, gotSecret := SplitBearerSliceString(tt.in)
if gotToken != tt.wantToken || gotSecret != tt.wantSecret {
t.Errorf("SplitBearerSliceString(%q) = (%q, %q), want (%q, %q)", tt.in, gotToken, gotSecret, tt.wantToken, tt.wantSecret)
}
})
}
}
34 changes: 20 additions & 14 deletions nipcash/cash_consolidate.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,14 @@ import "encoding/json"
// (NIP-CASH §Consolidating Tokens).
type CashConsolidateParams struct {
// Sources MUST contain at least two distinct sources (ErrTooFewSources),
// none bearer-identified (ErrBearerSource — this revision of
// cash_consolidate accepts only pubkey-identified sources).
// none bearer-identified (ErrBearerSource — bearer sources remain
// rejected: a bearer secret has no signature and no binding to the
// request carrying it, unlike a connection_key source's signed
// identity_event + attestation_event, which this revision does accept).
Sources []Source
// To is who the merged wallet belongs to — MUST be a pubkey (namedIdentity
// built via Pubkey); ErrConsolidateTargetNotPubkey otherwise. A
// *BearerTarget or connection_key Target is this revision's deferred
// scope, rejected client-side rather than left to fail server-side.
// To is who the merged wallet belongs to — any Target (pubkey,
// connection_key, or a *BearerTarget) is accepted; ErrConsolidateTargetInvalid
// only if left nil.
To Target
// MintSignature opts the merged wallet's token into mint provenance —
// independent of whether any source wallet had one.
Expand All @@ -23,11 +24,12 @@ type CashConsolidateParams struct {
// consolidateSourceParam is the wire shape of one entry in cash_consolidate's
// "sources" request array.
type consolidateSourceParam struct {
WalletPubkey string `json:"wallet_pubkey"`
IdentityType string `json:"identity_type,omitempty"`
IdentityValue string `json:"identity_value,omitempty"`
IdentityEvent string `json:"identity_event,omitempty"`
BearerSecret string `json:"bearer_secret,omitempty"`
WalletPubkey string `json:"wallet_pubkey"`
IdentityType string `json:"identity_type,omitempty"`
IdentityValue string `json:"identity_value,omitempty"`
IdentityEvent string `json:"identity_event,omitempty"`
AttestationEvent string `json:"attestation_event,omitempty"`
BearerSecret string `json:"bearer_secret,omitempty"`
}

// CashConsolidateRequest is cash_consolidate's wire request shape.
Expand All @@ -45,8 +47,8 @@ func (p CashConsolidateParams) Request() (CashConsolidateRequest, error) {
return CashConsolidateRequest{}, ErrTooFewSources
}
targetFieldsVal, ok := p.To.(targetFields)
if !ok || targetFieldsVal.identityType() != identityTypePubkey {
return CashConsolidateRequest{}, ErrConsolidateTargetNotPubkey
if !ok {
return CashConsolidateRequest{}, ErrConsolidateTargetInvalid
}

sources := make([]consolidateSourceParam, len(p.Sources))
Expand All @@ -57,7 +59,7 @@ func (p CashConsolidateParams) Request() (CashConsolidateRequest, error) {
NewIdentityHash: newIdentityHash(p.To),
AmountMillis: &amount,
}
identityType, identityValue, identityEvent, _, bearerSecret, err := src.Credential.buildProof(binding)
identityType, identityValue, identityEvent, attestationEvent, bearerSecret, err := src.Credential.buildProof(binding)
if err != nil {
return CashConsolidateRequest{}, err
}
Expand All @@ -72,13 +74,17 @@ func (p CashConsolidateParams) Request() (CashConsolidateRequest, error) {
if identityEvent != nil {
sources[i].IdentityEvent = string(identityEvent)
}
if attestationEvent != nil {
sources[i].AttestationEvent = string(attestationEvent)
}
}

return CashConsolidateRequest{
Sources: sources,
NewIdentity: cashTransferNewIdentityParam{
IdentityType: targetFieldsVal.identityType(),
IdentityValue: targetFieldsVal.identityValue(),
IAPubkey: targetFieldsVal.iaPubkey(),
},
MintSignature: p.MintSignature,
}, nil
Expand Down
46 changes: 43 additions & 3 deletions nipcash/cash_consolidate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,40 @@ func TestCashConsolidateParams_Request_BearerSourceRejected(t *testing.T) {
}
}

func TestCashConsolidateParams_Request_TargetMustBePubkey(t *testing.T) {
func TestCashConsolidateParams_Request_NilTargetRejected(t *testing.T) {
privKeyHex, _ := generateTestKeypair(t)
p := CashConsolidateParams{
Sources: []Source{
From(randomKeyHex(t), 1000, BySigning(privKeyHex)),
From(randomKeyHex(t), 1000, BySigning(privKeyHex)),
},
To: nil,
}
if _, err := p.Request(); err != ErrConsolidateTargetInvalid {
t.Fatalf("got %v, want ErrConsolidateTargetInvalid", err)
}
}

func TestCashConsolidateParams_Request_BearerTargetAccepted(t *testing.T) {
privKeyHex, _ := generateTestKeypair(t)
bt := NewBearerTarget()
p := CashConsolidateParams{
Sources: []Source{
From(randomKeyHex(t), 1000, BySigning(privKeyHex)),
From(randomKeyHex(t), 1000, BySigning(privKeyHex)),
},
To: bt,
}
req, err := p.Request()
if err != nil {
t.Fatalf("Request: %v", err)
}
if req.NewIdentity.IdentityType != identityTypeBearer || req.NewIdentity.IdentityValue != bt.identityValue() {
t.Fatalf("NewIdentity: %+v, want bearer/%s", req.NewIdentity, bt.identityValue())
}
}

func TestCashConsolidateParams_Request_ConnectionKeyTargetAccepted(t *testing.T) {
privKeyHex, _ := generateTestKeypair(t)
p := CashConsolidateParams{
Sources: []Source{
Expand All @@ -35,8 +68,15 @@ func TestCashConsolidateParams_Request_TargetMustBePubkey(t *testing.T) {
},
To: ConnectionKey("discord", "someone", "iapub"),
}
if _, err := p.Request(); err != ErrConsolidateTargetNotPubkey {
t.Fatalf("got %v, want ErrConsolidateTargetNotPubkey", err)
req, err := p.Request()
if err != nil {
t.Fatalf("Request: %v", err)
}
if req.NewIdentity.IdentityType != identityTypeConnectionKey {
t.Fatalf("NewIdentity.IdentityType: got %s, want connection_key", req.NewIdentity.IdentityType)
}
if req.NewIdentity.IAPubkey != "iapub" {
t.Fatalf("NewIdentity.IAPubkey: got %q, want \"iapub\" — previously dropped entirely, must now pass through", req.NewIdentity.IAPubkey)
}
}

Expand Down
12 changes: 12 additions & 0 deletions nipcash/cash_transfer.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,18 @@ type CashTransferResult struct {
RemainderWalletToken string
}

// RecipientToken resolves the three-way NewWalletToken ambiguity above:
// the cash token string the recipient needs to receive/redeem what was
// sent. originalToken is the token this transfer was placed from — the
// answer for the in-place-reassignment case. For a bearer target, still
// combine with BearerTarget.Secret(); this only resolves the token half.
func (r *CashTransferResult) RecipientToken(originalToken string) string {
if r.NewWalletToken != "" {
return r.NewWalletToken
}
return originalToken
}

// ParseResult parses cash_transfer's wire response, decrypting any
// *_wallet_token field with p.Credential's own privkey (see Credential's
// decryptDelivery doc comment for why a bearer credential's tokens instead
Expand Down
28 changes: 28 additions & 0 deletions nipcash/cash_transfer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,34 @@ func TestCashTransferParams_ParseResult_SpinOff_DecryptsToken(t *testing.T) {
}
}

func TestCashTransferResult_RecipientToken_InPlaceFallsBackToOriginal(t *testing.T) {
result := &CashTransferResult{NewWalletToken: ""}
got := result.RecipientToken("lokicash1original")
if got != "lokicash1original" {
t.Fatalf("RecipientToken() = %q, want the original token (in-place reassignment)", got)
}
}

func TestCashTransferResult_RecipientToken_SpinOffUsesNewWalletToken(t *testing.T) {
result := &CashTransferResult{NewWalletToken: "lokicash1spunoff"}
got := result.RecipientToken("lokicash1original")
if got != "lokicash1spunoff" {
t.Fatalf("RecipientToken() = %q, want the spun-off NewWalletToken", got)
}
}

func TestCashTransferResult_RecipientToken_PartialSplitUsesNewWalletToken(t *testing.T) {
// Both NewWalletToken and RemainderWalletToken set: the caller's own
// remainder is a separate concern (RemainderWalletToken), not
// RecipientToken's — this only ever answers "what does the recipient
// need."
result := &CashTransferResult{NewWalletToken: "lokicash1carvedoff", RemainderWalletToken: "lokicash1remainder"}
got := result.RecipientToken("lokicash1original")
if got != "lokicash1carvedoff" {
t.Fatalf("RecipientToken() = %q, want the carved-off NewWalletToken, not the caller's own remainder", got)
}
}

// encryptForTest mirrors decryptFromPubkey's own key derivation, in the
// opposite direction, to build a delivery ciphertext a real server would
// produce — ECDH is commutative, so deriving from (recipientPriv,
Expand Down
Loading
Loading