From 85b129c609c8f3c43bd8bdac8418264f29644004 Mon Sep 17 00:00:00 2001 From: naliyi <154817482+naliyi@users.noreply.github.com> Date: Tue, 15 Sep 2026 08:22:57 +0000 Subject: [PATCH 01/10] feat(nipcash): add ResolvedConnectionKey and cash_consolidate connection_key sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ResolvedConnectionKey builds a connection_key Recipient/Target directly from a nipIC.ConnectionKey the caller already has (e.g. decoded from an nconnection1... string via nipIC.DecodeNConnection), without needing the raw external ID ConnectionKey itself hashes internally. cash_consolidate's wire shape (consolidateSourceParam) had no attestation_event field at all, so a connection_key source's proof had nowhere to travel — added the field and wired Request() to populate it instead of discarding buildProof's attestation return value. Sources may now be pubkey or connection_key; bearer sources remain rejected (no signature, no binding to the request carrying it). --- CHANGELOG.md | 12 ++++++++++++ nipcash/cash_consolidate.go | 22 ++++++++++++++-------- nipcash/identity.go | 22 ++++++++++++++++------ nipcash/identity_test.go | 27 ++++++++++++++++++++++++++- 4 files changed, 68 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 904f2a3..1f71675 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## [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. + ## [0.2.9] ### Fixed diff --git a/nipcash/cash_consolidate.go b/nipcash/cash_consolidate.go index 1031b7f..e3345b7 100644 --- a/nipcash/cash_consolidate.go +++ b/nipcash/cash_consolidate.go @@ -7,8 +7,10 @@ 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 @@ -23,11 +25,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. @@ -57,7 +60,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 } @@ -72,6 +75,9 @@ func (p CashConsolidateParams) Request() (CashConsolidateRequest, error) { if identityEvent != nil { sources[i].IdentityEvent = string(identityEvent) } + if attestationEvent != nil { + sources[i].AttestationEvent = string(attestationEvent) + } } return CashConsolidateRequest{ diff --git a/nipcash/identity.go b/nipcash/identity.go index bb3ef1c..4937810 100644 --- a/nipcash/identity.go +++ b/nipcash/identity.go @@ -68,6 +68,17 @@ func ConnectionKey(platform nipIC.WebIdentity, externalID, iaPubkey string) name return namedIdentity{identity: nipAZ.Connection(platform, externalID), ia: iaPubkey} } +// ResolvedConnectionKey builds a Recipient/Target from a nipIC.ConnectionKey +// the caller already has — e.g. one decoded from an nconnection1... string +// via nipIC.DecodeNConnection, which carries the key itself, not the raw +// external ID it was hashed from. Unlike ConnectionKey, this never hashes +// anything (via nipAZ.ResolvedConnection): a caller with only the key, not +// the external ID, has no way to reproduce ConnectionKey's own hash step +// and shouldn't need to. +func ResolvedConnectionKey(key nipIC.ConnectionKey, platform nipIC.WebIdentity, iaPubkey string) namedIdentity { + return namedIdentity{identity: nipAZ.ResolvedConnection(key, platform), ia: iaPubkey} +} + // bearerRecipient is Anyone()'s concrete Recipient — plain cash, no // registered identity, redeemable by whoever holds the wallet's secret. // Deliberately satisfies only Recipient, not Target: see BearerTarget. @@ -143,12 +154,11 @@ func Send(recipient Recipient, amountMillis uint64) Allocation { // Source pairs a source wallet with its own current committed amount and // the Credential proving control over it, for CashConsolidate. Build one -// with From — a live Credential (BySigning; connection_key/bearer sources -// are rejected by this revision of NIP-CASH, see ErrBearerSource), or one -// built from a proof captured earlier via ByProof. Authorization is -// per-source, not per-connection, so a relayer holding only captured -// proofs can still consolidate on someone else's behalf (NIP-CASH -// §Consolidating Tokens). +// with From — a live Credential (BySigning; pubkey and connection_key both +// work, bearer sources are rejected, see ErrBearerSource), or one built +// from a proof captured earlier via ByProof. Authorization is per-source, +// not per-connection, so a relayer holding only captured proofs can still +// consolidate on someone else's behalf (NIP-CASH §Consolidating Tokens). type Source struct { WalletPubkey string // Amount is this source's own current committed amount, in millis — diff --git a/nipcash/identity_test.go b/nipcash/identity_test.go index 3716140..1eab02a 100644 --- a/nipcash/identity_test.go +++ b/nipcash/identity_test.go @@ -1,6 +1,10 @@ package nipcash -import "testing" +import ( + "testing" + + "github.com/ohstr/nmilat/nipIC" +) // Compile-time checks of the Recipient/Target split (see Target's own doc // comment): namedIdentity (Pubkey/ConnectionKey) satisfies both; bearerRecipient @@ -39,6 +43,27 @@ func TestPubkeyConnectionKeyAnyone_IdentityTypes(t *testing.T) { } } +func TestResolvedConnectionKey_MatchesConnectionKeyWithoutRehashing(t *testing.T) { + // ConnectionKey hashes (platform, externalID) internally; a caller who + // only has the already-hashed key (e.g. decoded from an nconnection1... + // string) has no externalID to feed it. ResolvedConnectionKey must + // produce an identical identity_type/identity_value/ia_pubkey given + // that same key directly, with no external ID involved at all. + viaExternalID := ConnectionKey("discord", "some.user", "iapub") + key := nipIC.NewConnectionKey("discord", "some.user") + viaKey := ResolvedConnectionKey(key, "discord", "iapub") + + if viaKey.identityType() != viaExternalID.identityType() { + t.Fatalf("identityType mismatch: got %s, want %s", viaKey.identityType(), viaExternalID.identityType()) + } + if viaKey.identityValue() != viaExternalID.identityValue() { + t.Fatalf("identityValue mismatch: got %s, want %s", viaKey.identityValue(), viaExternalID.identityValue()) + } + if viaKey.iaPubkey() != "iapub" { + t.Fatalf("iaPubkey: got %s, want iapub", viaKey.iaPubkey()) + } +} + func TestNewBearerTarget_SecretAndCommitmentDiffer(t *testing.T) { bt := NewBearerTarget() f := Target(bt).(targetFields) From 1585b6dd8439fd8a64402da67d40f9ca05e767ba Mon Sep 17 00:00:00 2001 From: naliyi <154817482+naliyi@users.noreply.github.com> Date: Wed, 16 Sep 2026 11:44:16 +0000 Subject: [PATCH 02/10] feat(nipcash): accept bearer/connection_key targets in cash_consolidate cash_consolidate's new_identity target was pubkey-only client-side, even though this NIP-CASH revision's server side already accepts bearer and connection_key targets too. ErrConsolidateTargetNotPubkey renamed to ErrConsolidateTargetInvalid: the only thing rejected client-side now is a nil To, not a real Target's own type. Also passes IAPubkey through for a connection_key target, previously dropped entirely. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 6 +++++ nipcash/cash_consolidate.go | 12 ++++----- nipcash/cash_consolidate_test.go | 46 +++++++++++++++++++++++++++++--- nipcash/nipcash.go | 11 +++++--- 4 files changed, 62 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f71675..da1e232 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,12 @@ for the common case; this is the counterpart for a caller starting from the key itself. +### Changed + +- `cash_consolidate` now accepts a bearer or connection_key `To` target, + not just pubkey. `ErrConsolidateTargetNotPubkey` renamed to + `ErrConsolidateTargetInvalid`. + ## [0.2.9] ### Fixed diff --git a/nipcash/cash_consolidate.go b/nipcash/cash_consolidate.go index e3345b7..355e3b1 100644 --- a/nipcash/cash_consolidate.go +++ b/nipcash/cash_consolidate.go @@ -12,10 +12,9 @@ type CashConsolidateParams struct { // 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. @@ -48,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)) @@ -85,6 +84,7 @@ func (p CashConsolidateParams) Request() (CashConsolidateRequest, error) { NewIdentity: cashTransferNewIdentityParam{ IdentityType: targetFieldsVal.identityType(), IdentityValue: targetFieldsVal.identityValue(), + IAPubkey: targetFieldsVal.iaPubkey(), }, MintSignature: p.MintSignature, }, nil diff --git a/nipcash/cash_consolidate_test.go b/nipcash/cash_consolidate_test.go index 6fa41c0..36a6b7b 100644 --- a/nipcash/cash_consolidate_test.go +++ b/nipcash/cash_consolidate_test.go @@ -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{ @@ -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) } } diff --git a/nipcash/nipcash.go b/nipcash/nipcash.go index 69c79f4..26e8b93 100644 --- a/nipcash/nipcash.go +++ b/nipcash/nipcash.go @@ -71,10 +71,13 @@ var ( // deferred to the server. ErrBearerSource = errors.New("nipcash: cash_consolidate does not accept a bearer-identified source") - // ErrConsolidateTargetNotPubkey is returned by CashConsolidate when the - // target identity isn't a bare pubkey — this revision of NIP-CASH only - // accepts a pubkey new_identity for a consolidated wallet. - ErrConsolidateTargetNotPubkey = errors.New("nipcash: cash_consolidate requires a pubkey new_identity") + // ErrConsolidateTargetInvalid is returned by CashConsolidate when To is + // nil — cash_consolidate itself now accepts a pubkey, connection_key, + // or bearer new_identity alike (previously pubkey-only; NIP-CASH's + // 2026-09-14 revision added bearer/connection_key consolidate targets + // server-side), so nothing about a real Target's own type is rejected + // here anymore, only a target that was never set at all. + ErrConsolidateTargetInvalid = errors.New("nipcash: cash_consolidate requires a new_identity target") // ErrWrongCredentialForTarget is returned when a BearerTarget is used // where a Recipient is expected, or vice versa — mint_cash's bearer From 0d6adc537c6c09a2e8eef5b784c9e6411310d612 Mon Sep 17 00:00:00 2001 From: naliyi <154817482+naliyi@users.noreply.github.com> Date: Wed, 16 Sep 2026 11:44:29 +0000 Subject: [PATCH 03/10] feat(nipcash): add SplitBearerSliceString for bearer slice string presentation Splits a bearer slice's optional combined "#" display-layer presentation back into its two parts, so a caller presenting the two together in one string has a symmetric way to pull them back apart on the receiving end. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 2 ++ nipcash/bearer_string.go | 22 ++++++++++++++++++++++ nipcash/bearer_string_test.go | 25 +++++++++++++++++++++++++ 3 files changed, 49 insertions(+) create mode 100644 nipcash/bearer_string.go create mode 100644 nipcash/bearer_string_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index da1e232..7782421 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ `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 + `"#"` presentation into its two parts. ### Changed diff --git a/nipcash/bearer_string.go b/nipcash/bearer_string.go new file mode 100644 index 0000000..60af443 --- /dev/null +++ b/nipcash/bearer_string.go @@ -0,0 +1,22 @@ +package nipcash + +import "strings" + +// SplitBearerSliceString splits a bearer slice's optional "#" +// 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 +} diff --git a/nipcash/bearer_string_test.go b/nipcash/bearer_string_test.go new file mode 100644 index 0000000..4f395a4 --- /dev/null +++ b/nipcash/bearer_string_test.go @@ -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) + } + }) + } +} From eb3c759769b3ded769510b21f141710d474ef384 Mon Sep 17 00:00:00 2001 From: naliyi <154817482+naliyi@users.noreply.github.com> Date: Wed, 16 Sep 2026 11:44:37 +0000 Subject: [PATCH 04/10] feat(nipcash): add CashTransferResult.RecipientToken Resolves the three-way NewWalletToken ambiguity on a transfer result (in-place reassignment vs. spin-off vs. partial split) into the one answer a caller actually needs: the token the recipient should use. Co-Authored-By: Claude Sonnet 5 --- nipcash/cash_transfer.go | 12 ++++++++++++ nipcash/cash_transfer_test.go | 28 ++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/nipcash/cash_transfer.go b/nipcash/cash_transfer.go index 87e177b..5b9aa67 100644 --- a/nipcash/cash_transfer.go +++ b/nipcash/cash_transfer.go @@ -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 diff --git a/nipcash/cash_transfer_test.go b/nipcash/cash_transfer_test.go index d798ca9..483499c 100644 --- a/nipcash/cash_transfer_test.go +++ b/nipcash/cash_transfer_test.go @@ -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, From 2436fbb83a5d39d874739563074c4aed18d90364 Mon Sep 17 00:00:00 2001 From: naliyi <154817482+naliyi@users.noreply.github.com> Date: Wed, 16 Sep 2026 11:44:45 +0000 Subject: [PATCH 05/10] feat(nipcash): add IsBearer and claim-matching helpers RecipientStatus.IsBearer keeps identityTypeBearer's own comparison inside the package. MatchClaim/MatchClaimAuto are the pure matching rule CheckClaim (nipcash/client) builds on: only ever considers an unclaimed recipient a match, bearer matches any unclaimed bearer entry, non-bearer matches only an unclaimed entry whose IdentityValue equals the caller's own pubkey exactly. Co-Authored-By: Claude Sonnet 5 --- nipcash/claim.go | 83 ++++++++++++++++++++ nipcash/claim_test.go | 150 +++++++++++++++++++++++++++++++++++++ nipcash/list_recipients.go | 6 ++ 3 files changed, 239 insertions(+) create mode 100644 nipcash/claim.go create mode 100644 nipcash/claim_test.go diff --git a/nipcash/claim.go b/nipcash/claim.go new file mode 100644 index 0000000..981e965 --- /dev/null +++ b/nipcash/claim.go @@ -0,0 +1,83 @@ +package nipcash + +import "errors" + +// NoLocalIdentity documents an absent identity at CheckClaim/MatchClaim +// call sites — same pattern as the stdlib's http.NoBody: the underlying +// type is still a plain string ("" can never be a valid hex pubkey, so +// it's already an unambiguous sentinel on its own, the same convention +// bearerRecipient.identityValue() already uses for "not applicable"), +// this just gives call sites a name instead of an unexplained "". +const NoLocalIdentity = "" + +// ErrClaimNotFound is returned by CheckClaim when tok has no matching, +// unclaimed recipient on the Cash Hub it's checked against. +var ErrClaimNotFound = errors.New("nipcash: no matching, unclaimed recipient on this Cash Hub") + +// CheckClaimResult is everything a caller needs to decide whether/how to +// act on a cash bill, resolved by actually asking its Cash Hub — no +// local storage concept; that's entirely the caller's own. +type CheckClaimResult struct { + IsBearer bool + AmountMillis uint64 // authoritative, from list_recipients + MinterPubkey *string // non-nil only if a verified mint-provenance signature is present + // RedeemFeeMillis/NetRedeemableMillis/ExpiresAt mirror the matched + // RecipientStatus row's own fields — surfaced here so a caller + // building a pre-spend preview doesn't need a second lookup. + RedeemFeeMillis uint64 + NetRedeemableMillis uint64 + ExpiresAt *int64 +} + +// MatchClaim is the pure matching rule CheckClaim uses — kept as its own +// function (not inlined into the network-calling method) so it's +// unit-testable with a hand-built []RecipientStatus, no dial needed, +// mirroring VerifyProvenance's own split (protocol logic lives in +// nipcash, not nipcash/client). Only ever considers an unclaimed +// recipient a match: bearer matches any unclaimed bearer entry, +// non-bearer matches only an unclaimed entry whose IdentityValue equals +// asPubkeyHex exactly. asPubkeyHex == NoLocalIdentity never matches a +// non-bearer entry (no local identity to compare against). +// +// connection_key matching isn't implemented — a caller checking a +// connection_key-bound token gets no match today, same as before this +// existed. +func MatchClaim(recipients []RecipientStatus, isBearer bool, asPubkeyHex string) (amountMillis uint64, ok bool) { + for _, r := range recipients { + if r.Claimed { + continue + } + matched := (isBearer && r.IdentityType == identityTypeBearer) || + (!isBearer && r.IdentityType == identityTypePubkey && asPubkeyHex != NoLocalIdentity && r.IdentityValue == asPubkeyHex) + if matched { + return r.AmountMillis, true + } + } + return 0, false +} + +// MatchClaimAuto is MatchClaim's caller-friendly wrapper: a caller +// checking a bill doesn't reliably know in advance whether it's +// bearer-mode or identity-bound — a token's identity_required TLV is +// only "a best-effort hint... NOT a live guarantee" (§Redemption +// Metadata), since it can go stale after the wallet it describes is +// reassigned. Tries a pubkey match (if asPubkeyHex is given) then a +// bearer match, live. Safe, not ambiguous: a wallet is always all-bearer +// or all identity-bound, never mixed, so at most one attempt can match. +// +// Returns the full matched RecipientStatus row, not just its amount, so +// a caller can also read net_redeemable/redeem_fee/expires_at off it +// without a second lookup. +func MatchClaimAuto(recipients []RecipientStatus, asPubkeyHex string) (recipient RecipientStatus, ok bool) { + for _, r := range recipients { + if r.Claimed { + continue + } + matched := r.IsBearer() || + (r.IdentityType == identityTypePubkey && asPubkeyHex != NoLocalIdentity && r.IdentityValue == asPubkeyHex) + if matched { + return r, true + } + } + return RecipientStatus{}, false +} diff --git a/nipcash/claim_test.go b/nipcash/claim_test.go new file mode 100644 index 0000000..daff09d --- /dev/null +++ b/nipcash/claim_test.go @@ -0,0 +1,150 @@ +package nipcash + +import "testing" + +func TestMatchClaim_BearerUnclaimedMatches(t *testing.T) { + recipients := []RecipientStatus{ + {IdentityType: identityTypeBearer, AmountMillis: 5000, Claimed: false}, + } + amount, ok := MatchClaim(recipients, true, NoLocalIdentity) + if !ok || amount != 5000 { + t.Fatalf("MatchClaim() = (%d, %v), want (5000, true)", amount, ok) + } +} + +func TestMatchClaim_BearerAlreadyClaimedDoesNotMatch(t *testing.T) { + // The exact regression independent review caught: an already-redeemed + // bearer recipient must not be reported as a live match just because + // its identity_type still says "bearer". + recipients := []RecipientStatus{ + {IdentityType: identityTypeBearer, AmountMillis: 5000, Claimed: true}, + } + if _, ok := MatchClaim(recipients, true, NoLocalIdentity); ok { + t.Fatal("MatchClaim() matched an already-claimed bearer recipient") + } +} + +func TestMatchClaim_PubkeyExactMatchUnclaimed(t *testing.T) { + recipients := []RecipientStatus{ + {IdentityType: identityTypePubkey, IdentityValue: "abc123", AmountMillis: 3000, Claimed: false}, + } + amount, ok := MatchClaim(recipients, false, "abc123") + if !ok || amount != 3000 { + t.Fatalf("MatchClaim() = (%d, %v), want (3000, true)", amount, ok) + } +} + +func TestMatchClaim_PubkeyAlreadyClaimedDoesNotMatch(t *testing.T) { + recipients := []RecipientStatus{ + {IdentityType: identityTypePubkey, IdentityValue: "abc123", AmountMillis: 3000, Claimed: true}, + } + if _, ok := MatchClaim(recipients, false, "abc123"); ok { + t.Fatal("MatchClaim() matched an already-claimed pubkey recipient") + } +} + +func TestMatchClaim_NoLocalIdentityNeverMatchesPubkey(t *testing.T) { + recipients := []RecipientStatus{ + {IdentityType: identityTypePubkey, IdentityValue: "abc123", AmountMillis: 3000, Claimed: false}, + } + if _, ok := MatchClaim(recipients, false, NoLocalIdentity); ok { + t.Fatal("MatchClaim() matched a pubkey recipient with no local identity supplied") + } +} + +func TestMatchClaim_WrongPubkeyDoesNotMatch(t *testing.T) { + recipients := []RecipientStatus{ + {IdentityType: identityTypePubkey, IdentityValue: "abc123", AmountMillis: 3000, Claimed: false}, + } + if _, ok := MatchClaim(recipients, false, "someone-else"); ok { + t.Fatal("MatchClaim() matched a pubkey recipient belonging to a different identity") + } +} + +func TestMatchClaim_NoRecipientsNoMatch(t *testing.T) { + if _, ok := MatchClaim(nil, true, NoLocalIdentity); ok { + t.Fatal("MatchClaim() matched against an empty recipient list") + } +} + +func TestMatchClaimAuto_BearerWalletMatchesRegardlessOfSuppliedPubkey(t *testing.T) { + // A pubkey is supplied but the wallet is actually bearer-mode — + // must still find the real bearer recipient. + recipients := []RecipientStatus{ + {IdentityType: identityTypeBearer, AmountMillis: 7000, Claimed: false}, + } + recipient, ok := MatchClaimAuto(recipients, "some-caller-pubkey") + if !ok || !recipient.IsBearer() || recipient.AmountMillis != 7000 { + t.Fatalf("MatchClaimAuto() = (%+v, %v), want (amount 7000, bearer, true)", recipient, ok) + } +} + +func TestMatchClaimAuto_PubkeyWalletMatchesWithoutNeedingABearerHint(t *testing.T) { + recipients := []RecipientStatus{ + {IdentityType: identityTypePubkey, IdentityValue: "abc123", AmountMillis: 4000, Claimed: false}, + } + recipient, ok := MatchClaimAuto(recipients, "abc123") + if !ok || recipient.IsBearer() || recipient.AmountMillis != 4000 { + t.Fatalf("MatchClaimAuto() = (%+v, %v), want (amount 4000, not bearer, true)", recipient, ok) + } +} + +func TestMatchClaimAuto_NoLocalIdentitySkipsPubkeyAttemptButStillFindsBearer(t *testing.T) { + recipients := []RecipientStatus{ + {IdentityType: identityTypeBearer, AmountMillis: 2500, Claimed: false}, + } + recipient, ok := MatchClaimAuto(recipients, NoLocalIdentity) + if !ok || !recipient.IsBearer() || recipient.AmountMillis != 2500 { + t.Fatalf("MatchClaimAuto() = (%+v, %v), want (amount 2500, bearer, true)", recipient, ok) + } +} + +func TestMatchClaimAuto_WrongPubkeyAndNoBearerRecipientNoMatch(t *testing.T) { + recipients := []RecipientStatus{ + {IdentityType: identityTypePubkey, IdentityValue: "abc123", AmountMillis: 4000, Claimed: false}, + } + if _, ok := MatchClaimAuto(recipients, "someone-else"); ok { + t.Fatal("MatchClaimAuto() matched a pubkey recipient belonging to a different identity") + } +} + +func TestMatchClaimAuto_AlreadyClaimedRecipientNoMatch(t *testing.T) { + recipients := []RecipientStatus{ + {IdentityType: identityTypeBearer, AmountMillis: 5000, Claimed: true}, + } + if _, ok := MatchClaimAuto(recipients, NoLocalIdentity); ok { + t.Fatal("MatchClaimAuto() matched an already-claimed recipient") + } +} + +func TestMatchClaimAuto_NoRecipientsNoMatch(t *testing.T) { + if _, ok := MatchClaimAuto(nil, "abc123"); ok { + t.Fatal("MatchClaimAuto() matched against an empty recipient list") + } +} + +// TestMatchClaimAuto_ReturnsFullRowNotJustAmount confirms fee/expiry +// data rides along with the match, not just the amount. +func TestMatchClaimAuto_ReturnsFullRowNotJustAmount(t *testing.T) { + expiresAt := int64(1234567890) + recipients := []RecipientStatus{ + { + IdentityType: identityTypePubkey, + IdentityValue: "abc123", + AmountMillis: 4000, + RedeemFeeMillis: 50, + NetRedeemableMillis: 3950, + ExpiresAt: &expiresAt, + }, + } + recipient, ok := MatchClaimAuto(recipients, "abc123") + if !ok { + t.Fatal("MatchClaimAuto() unexpectedly found no match") + } + if recipient.RedeemFeeMillis != 50 || recipient.NetRedeemableMillis != 3950 { + t.Errorf("MatchClaimAuto() fee fields = (%d, %d), want (50, 3950)", recipient.RedeemFeeMillis, recipient.NetRedeemableMillis) + } + if recipient.ExpiresAt == nil || *recipient.ExpiresAt != expiresAt { + t.Errorf("MatchClaimAuto() ExpiresAt = %v, want %d", recipient.ExpiresAt, expiresAt) + } +} diff --git a/nipcash/list_recipients.go b/nipcash/list_recipients.go index 985299c..2df425c 100644 --- a/nipcash/list_recipients.go +++ b/nipcash/list_recipients.go @@ -28,3 +28,9 @@ type RecipientStatus struct { type ListRecipientsResult struct { Recipients []RecipientStatus `json:"recipients"` } + +// IsBearer reports whether r is a bearer-mode recipient row — the one +// place identityTypeBearer's own comparison lives, so a caller outside +// this package (nipcash/client's CheckClaim, say) never needs the +// unexported wire constant itself just to ask this question. +func (r RecipientStatus) IsBearer() bool { return r.IdentityType == identityTypeBearer } From d8a341e37070eaddf41c3008ac8e11616f74b780 Mon Sep 17 00:00:00 2001 From: naliyi <154817482+naliyi@users.noreply.github.com> Date: Wed, 16 Sep 2026 11:44:57 +0000 Subject: [PATCH 06/10] feat(nipcash/client): add CheckClaim to verify a token's recipient before acting on it Confirms a matching, unclaimed recipient actually exists on the Hub for a token, live via ListRecipients + MatchClaimAuto, rather than trusting a token's own identity_required TLV (a stale-prone hint, not a live guarantee). A method, not a self-dialing package function: several callers already hold a live, connected *Client for the same wallet by the time they need this check. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 2 ++ nipcash/client/check_claim.go | 56 +++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 nipcash/client/check_claim.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 7782421..64aef58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ the key itself. - `nipcash.SplitBearerSliceString`: splits a bearer slice's combined `"#"` 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). ### Changed diff --git a/nipcash/client/check_claim.go b/nipcash/client/check_claim.go new file mode 100644 index 0000000..08efa57 --- /dev/null +++ b/nipcash/client/check_claim.go @@ -0,0 +1,56 @@ +package client + +import ( + "context" + + "github.com/ohstr/nmilat/nipcash" +) + +// CheckClaim confirms a matching, unclaimed recipient actually exists on +// c's own Cash Hub connection for tok — via nipcash.MatchClaimAuto, tried +// live against list_recipients rather than gated on tok's own +// identity_required TLV (a stale-prone hint, not a live guarantee — see +// MatchClaimAuto). asPubkeyHex is the caller's own local pubkey if it +// has one; a bearer match is always attempted regardless. Returns +// nipcash.ErrClaimNotFound, not one of its own, if nothing matches. +// +// For a bearer token, a match only proves *some* unclaimed bearer +// recipient exists — list_recipients carries no identity_value for a +// bearer entry, so this can never confirm the *specific* secret the +// caller holds is the one still valid (only redemption itself proves +// that). Don't read a successful CheckClaim as a stronger guarantee than +// the protocol actually gives for the bearer case. +// +// Deliberately a method, not a self-dialing package function: several +// callers (a transfer/consolidate/redeem already mid-flow) already hold +// a live, connected *Client for the same wallet by the time they need +// this check — a self-dialing version would force a wasteful second +// connection. Connect remains the only place a raw token/pairing string +// is ever consumed. +func (c *Client) CheckClaim(ctx context.Context, tok nipcash.Token, asPubkeyHex string) (*nipcash.CheckClaimResult, error) { + result, err := c.ListRecipients(ctx) + if err != nil { + return nil, err + } + + recipient, ok := nipcash.MatchClaimAuto(result.Recipients, asPubkeyHex) + if !ok { + return nil, nipcash.ErrClaimNotFound + } + + var minterPubkey *string + if tok.HasProvenance() { + if minter, valid := nipcash.VerifyProvenance(tok); valid { + minterPubkey = &minter + } + } + + return &nipcash.CheckClaimResult{ + IsBearer: recipient.IsBearer(), + AmountMillis: recipient.AmountMillis, + MinterPubkey: minterPubkey, + RedeemFeeMillis: recipient.RedeemFeeMillis, + NetRedeemableMillis: recipient.NetRedeemableMillis, + ExpiresAt: recipient.ExpiresAt, + }, nil +} From ca83255985ec8cc27c267a82ef7dbf4267284f3b Mon Sep 17 00:00:00 2001 From: naliyi <154817482+naliyi@users.noreply.github.com> Date: Wed, 16 Sep 2026 11:45:11 +0000 Subject: [PATCH 07/10] feat(nipcash/client): add transferConsolidater interface and PartialProgressError transferConsolidater is the narrow slice of *Client that RekeyBearerSlice/TransferFromSources actually call, factored out so their own call-sequencing logic is unit-testable against a hand-built fake, without a live Cash Hub. PartialProgressError is returned by both when their first wire call lands for real but their second then fails -- exactly one of Transferred/Consolidated is set, whichever call type actually succeeded, so the caller can update its own ledger from whichever landed without an extra round trip. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 2 ++ nipcash/client/partial_progress.go | 30 +++++++++++++++++++++++++ nipcash/client/transfer_consolidater.go | 21 +++++++++++++++++ 3 files changed, 53 insertions(+) create mode 100644 nipcash/client/partial_progress.go create mode 100644 nipcash/client/transfer_consolidater.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 64aef58..3a77165 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ `"#"` 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/client.PartialProgressError`: reports partial progress when the + first of two chained calls above lands but the second fails. ### Changed diff --git a/nipcash/client/partial_progress.go b/nipcash/client/partial_progress.go new file mode 100644 index 0000000..8c3cab9 --- /dev/null +++ b/nipcash/client/partial_progress.go @@ -0,0 +1,30 @@ +package client + +import "github.com/ohstr/nmilat/nipcash" + +// PartialProgressError is returned by both TransferFromSources and +// RekeyBearerSlice when their first wire call landed for real but their +// second then failed — exactly one of Transferred/Consolidated is set, +// whichever call type actually succeeded (the two composites run the +// same two calls in opposite order: TransferFromSources consolidates +// then transfers, RekeyBearerSlice reassigns [a transfer] then +// consolidates). Both nipcash.CashTransferResult and +// nipcash.CashConsolidateResult already carry AmountMillis/ +// NewWalletPubkey/NewWalletToken, so the caller can update its own +// ledger from whichever landed without an extra round trip to +// rediscover what already happened. +type PartialProgressError struct { + Transferred *nipcash.CashTransferResult + Consolidated *nipcash.CashConsolidateResult + // Cause is the error the second (failed) call actually returned. + Cause error +} + +func (e *PartialProgressError) Error() string { + if e.Transferred != nil { + return "nipcash/client: interim transfer landed, but the following consolidate failed: " + e.Cause.Error() + } + return "nipcash/client: interim consolidate landed, but the following transfer failed: " + e.Cause.Error() +} + +func (e *PartialProgressError) Unwrap() error { return e.Cause } diff --git a/nipcash/client/transfer_consolidater.go b/nipcash/client/transfer_consolidater.go new file mode 100644 index 0000000..e88af78 --- /dev/null +++ b/nipcash/client/transfer_consolidater.go @@ -0,0 +1,21 @@ +package client + +import ( + "context" + + "github.com/ohstr/nmilat/nipcash" +) + +// transferConsolidater is the narrow slice of *Client that +// RekeyBearerSlice/TransferFromSources actually call — *Client already +// satisfies this. Factored out purely so their own call-sequencing logic +// (which call, in what order, on what condition) is unit-testable against +// a hand-built fake, without a live Cash Hub or a fake NWC relay: this +// package has no existing fake-transport harness (the only one in this +// repo, relay/client/nwc_test.go's newFakeWalletServer, is unexported one +// package over), and building one is a bigger, separate investment this +// doesn't need. +type transferConsolidater interface { + CashTransfer(ctx context.Context, params nipcash.CashTransferParams) (*nipcash.CashTransferResult, error) + CashConsolidate(ctx context.Context, params nipcash.CashConsolidateParams) (*nipcash.CashConsolidateResult, error) +} From 608f2b1b291e55c373b3298137312220389e7f89 Mon Sep 17 00:00:00 2001 From: naliyi <154817482+naliyi@users.noreply.github.com> Date: Wed, 16 Sep 2026 11:45:28 +0000 Subject: [PATCH 08/10] feat(nipcash/client): add RekeyBearerSlice to re-key a bearer slice under a fresh secret Moves a bearer-mode slice out of shared custody: the current secret is presented once, consumed, and replaced with a fresh secret only the caller knows. Optionally merges the slice with other same-issuer sources into a freshly-minted bearer wallet -- requires an interim reassignment onto a pubkey identity first, since cash_consolidate never accepts a bearer-identified source. nipcash.IsPubkeyTarget validates that interim identity client-side, before any wire call. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 3 + .../client/fake_transfer_consolidater_test.go | 45 ++++++ nipcash/client/rekey_bearer_slice.go | 138 ++++++++++++++++++ nipcash/client/rekey_bearer_slice_test.go | 128 ++++++++++++++++ nipcash/identity.go | 10 ++ 5 files changed, 324 insertions(+) create mode 100644 nipcash/client/fake_transfer_consolidater_test.go create mode 100644 nipcash/client/rekey_bearer_slice.go create mode 100644 nipcash/client/rekey_bearer_slice_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a77165..8e3b240 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,9 @@ `"#"` 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.PartialProgressError`: reports partial progress when the first of two chained calls above lands but the second fails. diff --git a/nipcash/client/fake_transfer_consolidater_test.go b/nipcash/client/fake_transfer_consolidater_test.go new file mode 100644 index 0000000..d26b17c --- /dev/null +++ b/nipcash/client/fake_transfer_consolidater_test.go @@ -0,0 +1,45 @@ +package client + +import ( + "context" + + "github.com/ohstr/nmilat/nipcash" +) + +// fakeTransferConsolidater is a hand-built transferConsolidater for +// exercising RekeyBearerSlice/TransferFromSources' own call-sequencing +// logic without a network — see transferConsolidater's own doc comment. +// closed counts Close calls (transferFromSources must release whatever +// reconnect hands it); reconnectCalls records every walletToken +// transferFromSources asked to rebind to, so a test can assert it's +// always the interim consolidate's own NewWalletToken, never the +// original dial target. +type fakeTransferConsolidater struct { + transferFunc func(params nipcash.CashTransferParams) (*nipcash.CashTransferResult, error) + consolidateFunc func(params nipcash.CashConsolidateParams) (*nipcash.CashConsolidateResult, error) + reconnectFunc func(walletToken string) (transferConsolidaterCloser, error) + transferCalls []nipcash.CashTransferParams + consolidateCalls []nipcash.CashConsolidateParams + reconnectCalls []string + closed int +} + +func (f *fakeTransferConsolidater) CashTransfer(_ context.Context, params nipcash.CashTransferParams) (*nipcash.CashTransferResult, error) { + f.transferCalls = append(f.transferCalls, params) + return f.transferFunc(params) +} + +func (f *fakeTransferConsolidater) CashConsolidate(_ context.Context, params nipcash.CashConsolidateParams) (*nipcash.CashConsolidateResult, error) { + f.consolidateCalls = append(f.consolidateCalls, params) + return f.consolidateFunc(params) +} + +func (f *fakeTransferConsolidater) reconnect(_ context.Context, walletToken string) (transferConsolidaterCloser, error) { + f.reconnectCalls = append(f.reconnectCalls, walletToken) + if f.reconnectFunc != nil { + return f.reconnectFunc(walletToken) + } + return f, nil +} + +func (f *fakeTransferConsolidater) Close() { f.closed++ } diff --git a/nipcash/client/rekey_bearer_slice.go b/nipcash/client/rekey_bearer_slice.go new file mode 100644 index 0000000..52b2795 --- /dev/null +++ b/nipcash/client/rekey_bearer_slice.go @@ -0,0 +1,138 @@ +package client + +import ( + "context" + "errors" + + "github.com/ohstr/nmilat/nipcash" +) + +// ErrInterimIdentityNotPubkey is returned by RekeyBearerSlice when +// ConsolidateWith is non-empty and InterimIdentity isn't a pubkey target +// — checked client-side, before any wire call, since this composite's +// interim step is a CashTransfer (which has no target-type restriction of +// its own): an unvalidated bad InterimIdentity would otherwise succeed +// for real, consuming the original bearer secret, before the following +// CashConsolidate then rejected it. +var ErrInterimIdentityNotPubkey = errors.New("nipcash/client: RekeyBearerSlice requires InterimIdentity to be a pubkey target") + +// RekeyBearerSliceParams moves a bearer-mode slice out of shared custody: +// BearerSlice's own secret is presented once, consumed, and replaced with +// a fresh secret only the caller knows — optionally consolidating the +// slice with other same-issuer sources the caller already controls into +// that same fresh bearer note. +// +// Consolidating requires an interim reassignment first: cash_consolidate +// never accepts a bearer-identified source (nipcash.ErrBearerSource — a +// bearer secret has no signature or binding, so a co-recipient of +// whatever connection placed the call could read and race it), so the +// slice must first move onto an identity cash_consolidate does accept. +// InterimIdentity/InterimCredential describe that identity and how to +// prove control of it afterward — both required together, and only, when +// ConsolidateWith is non-empty. +type RekeyBearerSliceParams struct { + // BearerSlice.Credential MUST be a nipcash.BySecret(...) value — this + // is always a bearer slice by definition. + BearerSlice nipcash.Source + // InterimIdentity MUST be a pubkey target (see + // ErrInterimIdentityNotPubkey), required iff ConsolidateWith is + // non-empty. + InterimIdentity nipcash.Target + InterimCredential nipcash.Credential + ConsolidateWith []nipcash.Source // none may be bearer-identified + MintSignature bool +} + +// RekeyBearerSliceResult is RekeyBearerSlice's outcome. NewToken == "" +// means the slice was re-keyed in place with nothing consolidated; +// non-empty means a new consolidated wallet was minted — no separate +// bool needed to tell the two apart. +type RekeyBearerSliceResult struct { + NewSecret string + NewWalletPubkey string + NewToken string + AmountMillis uint64 +} + +// RekeyBearerSlice moves BearerSlice out of shared bearer custody: its +// current secret is presented once, consumed, and replaced with a fresh +// secret only the caller knows (returned as NewSecret — the only copy, +// persist it immediately). With ConsolidateWith empty, this is a single +// in-place CashTransfer (bearer source ⇒ single-recipient-by-construction, +// so it's always in-place — NewToken stays ""). With ConsolidateWith +// non-empty, BearerSlice first reassigns onto InterimIdentity (also +// always in-place), then consolidates alongside ConsolidateWith into a +// freshly-minted bearer wallet (NewToken is then non-empty). +// +// If the interim reassignment succeeds but the following consolidate +// then fails, returns *PartialProgressError{Transferred: } — nothing about BearerSlice's original secret is +// recoverable at that point (it was genuinely consumed), so the caller +// must record InterimIdentity's real effect rather than treat the whole +// call as a no-op. +func (c *Client) RekeyBearerSlice(ctx context.Context, p RekeyBearerSliceParams) (*RekeyBearerSliceResult, error) { + return rekeyBearerSlice(ctx, c, p) +} + +// rekeyBearerSlice is RekeyBearerSlice's actual call-sequencing logic, +// against the narrow transferConsolidater interface rather than *Client +// directly — see transferConsolidater's own doc comment for why: this is +// what a test calls with a hand-built fake to exercise the branching +// (single vs. consolidated path, partial-failure error) without a +// network. +func rekeyBearerSlice(ctx context.Context, c transferConsolidater, p RekeyBearerSliceParams) (*RekeyBearerSliceResult, error) { + bt := nipcash.NewBearerTarget() + + if len(p.ConsolidateWith) == 0 { + result, err := c.CashTransfer(ctx, nipcash.CashTransferParams{ + Credential: p.BearerSlice.Credential, + To: bt, + CurrentAmount: p.BearerSlice.Amount, + MintSignature: p.MintSignature, + }) + if err != nil { + return nil, err + } + return &RekeyBearerSliceResult{ + NewSecret: bt.Secret(), + NewWalletPubkey: p.BearerSlice.WalletPubkey, + AmountMillis: result.AmountMillis, + }, nil + } + + if !nipcash.IsPubkeyTarget(p.InterimIdentity) { + return nil, ErrInterimIdentityNotPubkey + } + + interimResult, err := c.CashTransfer(ctx, nipcash.CashTransferParams{ + Credential: p.BearerSlice.Credential, + To: p.InterimIdentity, + CurrentAmount: p.BearerSlice.Amount, + MintSignature: p.MintSignature, + }) + if err != nil { + return nil, err + } + + thisSource := nipcash.Source{ + WalletPubkey: p.BearerSlice.WalletPubkey, + Amount: p.BearerSlice.Amount, + Credential: p.InterimCredential, + } + sources := append([]nipcash.Source{thisSource}, p.ConsolidateWith...) + result, err := c.CashConsolidate(ctx, nipcash.CashConsolidateParams{ + Sources: sources, + To: bt, + MintSignature: p.MintSignature, + }) + if err != nil { + return nil, &PartialProgressError{Transferred: interimResult, Cause: err} + } + + return &RekeyBearerSliceResult{ + NewSecret: bt.Secret(), + NewWalletPubkey: result.NewWalletPubkey, + NewToken: result.NewWalletToken, + AmountMillis: result.AmountMillis, + }, nil +} diff --git a/nipcash/client/rekey_bearer_slice_test.go b/nipcash/client/rekey_bearer_slice_test.go new file mode 100644 index 0000000..4dea713 --- /dev/null +++ b/nipcash/client/rekey_bearer_slice_test.go @@ -0,0 +1,128 @@ +package client + +import ( + "context" + "errors" + "testing" + + "github.com/ohstr/nmilat/nipcash" +) + +func TestRekeyBearerSlice_NoConsolidateWith_OneTransferOnly(t *testing.T) { + fake := &fakeTransferConsolidater{ + transferFunc: func(params nipcash.CashTransferParams) (*nipcash.CashTransferResult, error) { + return &nipcash.CashTransferResult{AmountMillis: 5000}, nil + }, + } + result, err := rekeyBearerSlice(context.Background(), fake, RekeyBearerSliceParams{ + BearerSlice: nipcash.Source{WalletPubkey: "wallet1", Amount: 5000, Credential: nipcash.BySecret("old-secret")}, + }) + if err != nil { + t.Fatalf("rekeyBearerSlice: %v", err) + } + if len(fake.transferCalls) != 1 || len(fake.consolidateCalls) != 0 { + t.Fatalf("calls: transfer=%d consolidate=%d, want 1/0", len(fake.transferCalls), len(fake.consolidateCalls)) + } + if result.NewToken != "" { + t.Fatalf("NewToken = %q, want \"\" (in-place, nothing consolidated)", result.NewToken) + } + if result.NewSecret == "" { + t.Fatal("NewSecret is empty — the only copy of the fresh secret must be returned") + } + if result.AmountMillis != 5000 { + t.Fatalf("AmountMillis = %d, want 5000", result.AmountMillis) + } +} + +func TestRekeyBearerSlice_ConsolidateWith_TransfersThenConsolidates(t *testing.T) { + const privKeyHex = "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1" + fake := &fakeTransferConsolidater{ + transferFunc: func(params nipcash.CashTransferParams) (*nipcash.CashTransferResult, error) { + return &nipcash.CashTransferResult{AmountMillis: 5000}, nil + }, + consolidateFunc: func(params nipcash.CashConsolidateParams) (*nipcash.CashConsolidateResult, error) { + return &nipcash.CashConsolidateResult{AmountMillis: 8000, NewWalletPubkey: "merged", NewWalletToken: "lokicash1merged"}, nil + }, + } + result, err := rekeyBearerSlice(context.Background(), fake, RekeyBearerSliceParams{ + BearerSlice: nipcash.Source{WalletPubkey: "wallet1", Amount: 5000, Credential: nipcash.BySecret("old-secret")}, + InterimIdentity: nipcash.Pubkey("myPubHex"), + InterimCredential: nipcash.BySigning(privKeyHex), + ConsolidateWith: []nipcash.Source{{WalletPubkey: "wallet2", Amount: 3000, Credential: nipcash.BySigning(privKeyHex)}}, + }) + if err != nil { + t.Fatalf("rekeyBearerSlice: %v", err) + } + if len(fake.transferCalls) != 1 || len(fake.consolidateCalls) != 1 { + t.Fatalf("calls: transfer=%d consolidate=%d, want 1/1", len(fake.transferCalls), len(fake.consolidateCalls)) + } + if len(fake.consolidateCalls[0].Sources) != 2 { + t.Fatalf("consolidate sources: got %d, want 2 (the reassigned slice + ConsolidateWith)", len(fake.consolidateCalls[0].Sources)) + } + if result.NewToken != "lokicash1merged" { + t.Fatalf("NewToken = %q, want the merged wallet's token", result.NewToken) + } + if result.AmountMillis != 8000 { + t.Fatalf("AmountMillis = %d, want 8000", result.AmountMillis) + } +} + +func TestRekeyBearerSlice_BadInterimIdentity_RejectedBeforeAnyWireCall(t *testing.T) { + // The exact fund-safety issue independent review caught: a bearer + // InterimIdentity here would let the interim CashTransfer succeed for + // real (consuming the original secret) before CashConsolidate then + // rejected it. Must be caught before touching the fake at all. + fake := &fakeTransferConsolidater{ + transferFunc: func(nipcash.CashTransferParams) (*nipcash.CashTransferResult, error) { + t.Fatal("CashTransfer must not be called when InterimIdentity is invalid") + return nil, nil + }, + consolidateFunc: func(nipcash.CashConsolidateParams) (*nipcash.CashConsolidateResult, error) { + t.Fatal("CashConsolidate must not be called when InterimIdentity is invalid") + return nil, nil + }, + } + _, err := rekeyBearerSlice(context.Background(), fake, RekeyBearerSliceParams{ + BearerSlice: nipcash.Source{WalletPubkey: "wallet1", Amount: 5000, Credential: nipcash.BySecret("old-secret")}, + InterimIdentity: nipcash.NewBearerTarget(), // invalid: not a pubkey target + ConsolidateWith: []nipcash.Source{{WalletPubkey: "wallet2", Amount: 3000}}, + }) + if !errors.Is(err, ErrInterimIdentityNotPubkey) { + t.Fatalf("got %v, want ErrInterimIdentityNotPubkey", err) + } + if len(fake.transferCalls) != 0 || len(fake.consolidateCalls) != 0 { + t.Fatalf("calls: transfer=%d consolidate=%d, want 0/0 — nothing should touch the wire", len(fake.transferCalls), len(fake.consolidateCalls)) + } +} + +func TestRekeyBearerSlice_InterimTransferLandsButConsolidateFails_PartialProgress(t *testing.T) { + const privKeyHex = "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1" + consolidateErr := errors.New("consolidate rejected") + fake := &fakeTransferConsolidater{ + transferFunc: func(params nipcash.CashTransferParams) (*nipcash.CashTransferResult, error) { + return &nipcash.CashTransferResult{AmountMillis: 5000, NewWalletPubkey: "wallet1"}, nil + }, + consolidateFunc: func(params nipcash.CashConsolidateParams) (*nipcash.CashConsolidateResult, error) { + return nil, consolidateErr + }, + } + _, err := rekeyBearerSlice(context.Background(), fake, RekeyBearerSliceParams{ + BearerSlice: nipcash.Source{WalletPubkey: "wallet1", Amount: 5000, Credential: nipcash.BySecret("old-secret")}, + InterimIdentity: nipcash.Pubkey("myPubHex"), + InterimCredential: nipcash.BySigning(privKeyHex), + ConsolidateWith: []nipcash.Source{{WalletPubkey: "wallet2", Amount: 3000}}, + }) + var partial *PartialProgressError + if !errors.As(err, &partial) { + t.Fatalf("got %v, want *PartialProgressError", err) + } + if partial.Transferred == nil { + t.Fatal("partial.Transferred is nil — the interim transfer's real result must be surfaced") + } + if partial.Consolidated != nil { + t.Fatal("partial.Consolidated must be nil — RekeyBearerSlice's interim step is a transfer, not a consolidate") + } + if !errors.Is(err, consolidateErr) { + t.Fatal("the underlying consolidate error must be unwrappable via errors.Is") + } +} diff --git a/nipcash/identity.go b/nipcash/identity.go index 4937810..e5b3ebf 100644 --- a/nipcash/identity.go +++ b/nipcash/identity.go @@ -140,6 +140,16 @@ func (t *BearerTarget) identityType() string { return identityTypeBearer } func (t *BearerTarget) identityValue() string { return t.commit } func (t *BearerTarget) iaPubkey() string { return "" } +// IsPubkeyTarget reports whether t identifies a bare Nostr pubkey — never +// bearer, never connection_key. Exported so nipcash/client composites +// that need to validate a Target's own type before making a wire call +// (e.g. rejecting a bad value before it can cause a real, partial +// side effect) don't need targetFields' otherwise-unexported shape. +func IsPubkeyTarget(t Target) bool { + tf, ok := t.(targetFields) + return ok && tf.identityType() == identityTypePubkey +} + // Allocation pairs a Recipient with the amount mint_cash funds their slice // with. Build one with Send. type Allocation struct { From f90ad407d3ca9a2643a6d2050ecfc76e615deb3e Mon Sep 17 00:00:00 2001 From: naliyi <154817482+naliyi@users.noreply.github.com> Date: Wed, 16 Sep 2026 11:45:51 +0000 Subject: [PATCH 09/10] feat(nipcash/client): add TransferFromSources to transfer an amount drawn from multiple sources Transfers Amount to To, drawing from one or more Sources. A single source transfers (or splits) directly; more than one consolidates first into InterimIdentity, then reconnects to that newly-consolidated wallet specifically before transferring onward -- cash_consolidate always spins off a genuinely new wallet pubkey, but CashTransfer's own proof-building binds to the Client's original dial-target pubkey, so acting through the original client on the new wallet would bind the proof to the wrong pubkey entirely (confirmed live: NOT_FOUND). Reconnecting first avoids that failure mode. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 8 + nipcash/client/transfer_from_sources.go | 156 ++++++++++++ nipcash/client/transfer_from_sources_test.go | 235 +++++++++++++++++++ 3 files changed, 399 insertions(+) create mode 100644 nipcash/client/transfer_from_sources.go create mode 100644 nipcash/client/transfer_from_sources_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e3b240..2052287 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,8 @@ - `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. @@ -27,6 +29,12 @@ 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. + ## [0.2.9] ### Fixed diff --git a/nipcash/client/transfer_from_sources.go b/nipcash/client/transfer_from_sources.go new file mode 100644 index 0000000..bf64a02 --- /dev/null +++ b/nipcash/client/transfer_from_sources.go @@ -0,0 +1,156 @@ +package client + +import ( + "context" + + "github.com/ohstr/nmilat/nipcash" +) + +// TransferFromSourcesParams transfers exactly Amount to To, drawing from +// Sources — a single source transfers (or splits) directly; more than +// one consolidates first into InterimIdentity, then transfers onward +// from there. Sources must already be consolidate-eligible if there's +// more than one (same-minter, non-bearer) — this is an app-level +// curation choice (a caller's own bookkeeping decides "same minter"; +// nipcash has no such concept), not a nipcash-enforced rule: +// CashConsolidateParams itself only checks ≥2 sources and rejects bearer +// sources/nil targets, nothing about minters. This function doesn't +// second-guess the caller's own selection either way. +type TransferFromSourcesParams struct { + Sources []nipcash.Source // ≥1, caller-resolved (live amount + credential) + Amount uint64 + To nipcash.Target + // InterimIdentity MUST be a pubkey target, required iff + // len(Sources) > 1. No client-side validation needed here (unlike + // RekeyBearerSlice's own InterimIdentity): this composite's *first* + // call is the interim CashConsolidate, and + // CashConsolidateParams.Request() already rejects a nil To before any + // network call at all — a bad value is caught for free, before + // anything moves. + InterimIdentity nipcash.Target + InterimCredential nipcash.Credential // proves control of InterimIdentity afterward + MintSignature bool +} + +// TransferFromSourcesResult is TransferFromSources' outcome. +// ConsolidatedFirst is nil if Sources had exactly one entry (no +// consolidate call was needed at all). +type TransferFromSourcesResult struct { + ConsolidatedFirst *nipcash.CashConsolidateResult + Transfer *nipcash.CashTransferResult +} + +// transferConsolidaterCloser is a transferConsolidater that can also be +// released — the shape reconnect returns, since a client dialed purely +// for TransferFromSources' own final call has no other owner to close it. +type transferConsolidaterCloser interface { + transferConsolidater + Close() +} + +// transferFromSourcesClient is TransferFromSources' own requirement, +// beyond transferConsolidater's two calls: rebinding to a different +// wallet after the interim consolidate. cash_consolidate always spins +// off a genuinely new wallet (new pubkey), but CashTransfer's own +// proof-building binds to *Client.WalletPubkey() specifically — the +// pubkey the Client was originally dialed against +// (nipcash/client/cash_transfer.go's own CashTransfer method). Acting on +// the newly-consolidated wallet through the *original* client would +// therefore bind the final transfer's proof to the wrong wallet pubkey +// entirely, and the Hub would reject it (confirmed live: NOT_FOUND). +// RekeyBearerSlice never needs this — its own interim step is a +// CashTransfer, which (per NIP-CASH's own rules) is always in-place for +// a bearer source, so its Client binding never goes stale. +type transferFromSourcesClient interface { + transferConsolidater + reconnect(ctx context.Context, walletToken string) (transferConsolidaterCloser, error) +} + +// reconnect dials a fresh client bound to walletToken. +func (c *Client) reconnect(ctx context.Context, walletToken string) (transferConsolidaterCloser, error) { + return Connect(ctx, walletToken) +} + +// TransferFromSources transfers Amount to To, drawing from Sources. A +// single source transfers (or splits, via CashTransferParams.SplitAmount) +// directly — NIP-CASH's wire format treats an omitted amount_millis and +// one equal to the source's own current amount as equivalent full +// transfers, so Amount == the source's own amount never accidentally +// forces a split. More than one source consolidates first into +// InterimIdentity (always spinning off a genuinely new wallet, with a +// new wallet pubkey), then reconnects to that new wallet specifically +// before transferring the combined total onward to To — see +// transferFromSourcesClient's own doc comment for why the reconnect is +// required, not optional. +// +// If the interim consolidate succeeds but the final transfer then fails +// (including a failure to even reconnect to the new wallet), returns +// *PartialProgressError{Consolidated: } so the caller can still record what actually +// landed on the wire. +func (c *Client) TransferFromSources(ctx context.Context, p TransferFromSourcesParams) (*TransferFromSourcesResult, error) { + return transferFromSources(ctx, c, p) +} + +// transferFromSources is TransferFromSources' actual call-sequencing +// logic, against transferFromSourcesClient rather than *Client directly +// — see transferConsolidater's own doc comment for why (the same +// no-network-testability reasoning, extended here with a reconnect step +// a fake can assert on independently). +func transferFromSources(ctx context.Context, c transferFromSourcesClient, p TransferFromSourcesParams) (*TransferFromSourcesResult, error) { + if len(p.Sources) == 1 { + src := p.Sources[0] + result, err := c.CashTransfer(ctx, nipcash.CashTransferParams{ + Credential: src.Credential, + To: p.To, + CurrentAmount: src.Amount, + SplitAmount: &p.Amount, + MintSignature: p.MintSignature, + }) + if err != nil { + return nil, err + } + return &TransferFromSourcesResult{Transfer: result}, nil + } + + consolidateResult, err := c.CashConsolidate(ctx, nipcash.CashConsolidateParams{ + Sources: p.Sources, + To: p.InterimIdentity, + MintSignature: p.MintSignature, + }) + if err != nil { + return nil, err + } + + finalClient, err := c.reconnect(ctx, consolidateResult.NewWalletToken) + if err != nil { + return nil, &PartialProgressError{Consolidated: consolidateResult, Cause: err} + } + defer finalClient.Close() + + transferResult, err := finalClient.CashTransfer(ctx, nipcash.CashTransferParams{ + Credential: p.InterimCredential, + To: p.To, + CurrentAmount: consolidateResult.AmountMillis, + SplitAmount: &p.Amount, + MintSignature: p.MintSignature, + }) + if err != nil { + return nil, &PartialProgressError{Consolidated: consolidateResult, Cause: err} + } + + // p.Amount is always sent as an explicit SplitAmount above, even when + // it equals the consolidated wallet's full amount — NIP-CASH treats + // that as a plain full transfer, reassigning the SAME wallet in + // place rather than spinning off a distinct one. transferResult then + // reports no new wallet and no remainder, even though real money + // landed on the interim wallet, now reassigned to p.To. Patched in + // here, or RecipientToken would answer with a source token that's + // already been consolidated away. + if transferResult.NewWalletToken == "" && transferResult.RemainderWalletToken == "" { + transferResult.NewWalletToken = consolidateResult.NewWalletToken + transferResult.NewWalletPubkey = consolidateResult.NewWalletPubkey + } + + return &TransferFromSourcesResult{ConsolidatedFirst: consolidateResult, Transfer: transferResult}, nil +} diff --git a/nipcash/client/transfer_from_sources_test.go b/nipcash/client/transfer_from_sources_test.go new file mode 100644 index 0000000..286fc75 --- /dev/null +++ b/nipcash/client/transfer_from_sources_test.go @@ -0,0 +1,235 @@ +package client + +import ( + "context" + "errors" + "testing" + + "github.com/ohstr/nmilat/nipcash" +) + +func TestTransferFromSources_SingleSource_NoConsolidateCall(t *testing.T) { + fake := &fakeTransferConsolidater{ + transferFunc: func(params nipcash.CashTransferParams) (*nipcash.CashTransferResult, error) { + if params.SplitAmount == nil || *params.SplitAmount != 3000 { + t.Fatalf("SplitAmount = %v, want 3000", params.SplitAmount) + } + return &nipcash.CashTransferResult{AmountMillis: 3000}, nil + }, + } + result, err := transferFromSources(context.Background(), fake, TransferFromSourcesParams{ + Sources: []nipcash.Source{{WalletPubkey: "wallet1", Amount: 5000, Credential: nipcash.BySecret("s")}}, + Amount: 3000, + To: nipcash.Pubkey("recipient"), + }) + if err != nil { + t.Fatalf("transferFromSources: %v", err) + } + if len(fake.transferCalls) != 1 || len(fake.consolidateCalls) != 0 { + t.Fatalf("calls: transfer=%d consolidate=%d, want 1/0", len(fake.transferCalls), len(fake.consolidateCalls)) + } + if result.ConsolidatedFirst != nil { + t.Fatal("ConsolidatedFirst must be nil — a single source never needs a consolidate call") + } +} + +func TestTransferFromSources_MultipleSources_ConsolidatesThenTransfers(t *testing.T) { + fake := &fakeTransferConsolidater{ + consolidateFunc: func(params nipcash.CashConsolidateParams) (*nipcash.CashConsolidateResult, error) { + if len(params.Sources) != 2 { + t.Fatalf("consolidate sources: got %d, want 2", len(params.Sources)) + } + return &nipcash.CashConsolidateResult{AmountMillis: 8000, NewWalletPubkey: "interim"}, nil + }, + transferFunc: func(params nipcash.CashTransferParams) (*nipcash.CashTransferResult, error) { + if params.CurrentAmount != 8000 { + t.Fatalf("CurrentAmount = %d, want 8000 (the consolidated total)", params.CurrentAmount) + } + return &nipcash.CashTransferResult{AmountMillis: 8000}, nil + }, + } + result, err := transferFromSources(context.Background(), fake, TransferFromSourcesParams{ + Sources: []nipcash.Source{ + {WalletPubkey: "wallet1", Amount: 5000, Credential: nipcash.BySecret("s1")}, + {WalletPubkey: "wallet2", Amount: 3000, Credential: nipcash.BySecret("s2")}, + }, + Amount: 8000, + To: nipcash.Pubkey("recipient"), + InterimIdentity: nipcash.Pubkey("myPubHex"), + InterimCredential: nipcash.BySecret("interim-doesnt-need-real-crypto-here"), + }) + if err != nil { + t.Fatalf("transferFromSources: %v", err) + } + if len(fake.consolidateCalls) != 1 || len(fake.transferCalls) != 1 { + t.Fatalf("calls: consolidate=%d transfer=%d, want 1/1", len(fake.consolidateCalls), len(fake.transferCalls)) + } + if result.ConsolidatedFirst == nil { + t.Fatal("ConsolidatedFirst must be set — the interim consolidate actually ran") + } +} + +// TestTransferFromSources_ReconnectsToInterimWalletBeforeFinalTransfer is +// the regression test for a real, live-confirmed bug: the final transfer +// must run against a client rebound to the interim consolidate's own +// NewWalletToken — never the original client, which stays bound to +// whatever wallet TransferFromSources was originally dialed against. +// Reusing that stale binding built a CashTransfer proof against the +// wrong wallet pubkey, and the Hub rejected it (NOT_FOUND) every time. +func TestTransferFromSources_ReconnectsToInterimWalletBeforeFinalTransfer(t *testing.T) { + final := &fakeTransferConsolidater{ + transferFunc: func(params nipcash.CashTransferParams) (*nipcash.CashTransferResult, error) { + return &nipcash.CashTransferResult{AmountMillis: 8000}, nil + }, + } + original := &fakeTransferConsolidater{ + consolidateFunc: func(params nipcash.CashConsolidateParams) (*nipcash.CashConsolidateResult, error) { + return &nipcash.CashConsolidateResult{AmountMillis: 8000, NewWalletPubkey: "interim", NewWalletToken: "interim-token"}, nil + }, + reconnectFunc: func(walletToken string) (transferConsolidaterCloser, error) { + return final, nil + }, + } + + _, err := transferFromSources(context.Background(), original, TransferFromSourcesParams{ + Sources: []nipcash.Source{ + {WalletPubkey: "wallet1", Amount: 5000, Credential: nipcash.BySecret("s1")}, + {WalletPubkey: "wallet2", Amount: 3000, Credential: nipcash.BySecret("s2")}, + }, + Amount: 8000, + To: nipcash.Pubkey("recipient"), + InterimIdentity: nipcash.Pubkey("myPubHex"), + InterimCredential: nipcash.BySecret("interim"), + }) + if err != nil { + t.Fatalf("transferFromSources: %v", err) + } + + if len(original.reconnectCalls) != 1 || original.reconnectCalls[0] != "interim-token" { + t.Fatalf("reconnectCalls = %v, want exactly one call with the interim consolidate's own NewWalletToken", original.reconnectCalls) + } + if len(original.transferCalls) != 0 { + t.Fatalf("the ORIGINAL client's CashTransfer was called %d times, want 0 — the final transfer must happen on the reconnected client instead", len(original.transferCalls)) + } + if len(final.transferCalls) != 1 { + t.Fatalf("the RECONNECTED client's CashTransfer was called %d times, want 1", len(final.transferCalls)) + } + if final.closed != 1 { + t.Fatalf("reconnected client Close() called %d times, want 1 — TransferFromSources must release what it dials", final.closed) + } +} + +// TestTransferFromSources_ReconnectFailure_PartialProgress confirms a +// failure to even reconnect to the interim wallet is still reported as +// *PartialProgressError, not a bare error — the interim consolidate +// already landed for real either way, and the caller must not lose +// track of it just because the very next step (even just dialing) +// failed. +func TestTransferFromSources_ReconnectFailure_PartialProgress(t *testing.T) { + reconnectErr := errors.New("dial refused") + fake := &fakeTransferConsolidater{ + consolidateFunc: func(params nipcash.CashConsolidateParams) (*nipcash.CashConsolidateResult, error) { + return &nipcash.CashConsolidateResult{AmountMillis: 8000, NewWalletPubkey: "interim", NewWalletToken: "interim-token"}, nil + }, + reconnectFunc: func(walletToken string) (transferConsolidaterCloser, error) { + return nil, reconnectErr + }, + } + + _, err := transferFromSources(context.Background(), fake, TransferFromSourcesParams{ + Sources: []nipcash.Source{ + {WalletPubkey: "wallet1", Amount: 5000, Credential: nipcash.BySecret("s1")}, + {WalletPubkey: "wallet2", Amount: 3000, Credential: nipcash.BySecret("s2")}, + }, + Amount: 8000, + To: nipcash.Pubkey("recipient"), + InterimIdentity: nipcash.Pubkey("myPubHex"), + InterimCredential: nipcash.BySecret("interim"), + }) + var partial *PartialProgressError + if !errors.As(err, &partial) { + t.Fatalf("got %v, want *PartialProgressError", err) + } + if partial.Consolidated == nil { + t.Fatal("partial.Consolidated is nil — the interim consolidate's real result must be surfaced even on a reconnect failure") + } + if !errors.Is(err, reconnectErr) { + t.Fatal("the underlying reconnect error must be unwrappable via errors.Is") + } +} + +// TestTransferFromSources_ExactMatchFinalTransfer_SurfacesInterimWalletAsRecipientToken +// covers the exact-amount final transfer: the underlying CashTransfer +// response reports no NewWalletToken and no remainder (an in-place +// reassignment), so the interim wallet must be patched in — otherwise +// RecipientToken would point to an already-consolidated-away source. +func TestTransferFromSources_ExactMatchFinalTransfer_SurfacesInterimWalletAsRecipientToken(t *testing.T) { + fake := &fakeTransferConsolidater{ + consolidateFunc: func(params nipcash.CashConsolidateParams) (*nipcash.CashConsolidateResult, error) { + return &nipcash.CashConsolidateResult{AmountMillis: 8000, NewWalletPubkey: "interim-pub", NewWalletToken: "interim-token"}, nil + }, + transferFunc: func(params nipcash.CashTransferParams) (*nipcash.CashTransferResult, error) { + // The server's real behavior for this exact case: an explicit + // SplitAmount equal to CurrentAmount still reassigns in place, + // reporting neither a new wallet nor a remainder. + return &nipcash.CashTransferResult{AmountMillis: 8000}, nil + }, + } + result, err := transferFromSources(context.Background(), fake, TransferFromSourcesParams{ + Sources: []nipcash.Source{ + {WalletPubkey: "wallet1", Amount: 5000, Credential: nipcash.BySecret("s1")}, + {WalletPubkey: "wallet2", Amount: 3000, Credential: nipcash.BySecret("s2")}, + }, + Amount: 8000, // exactly the consolidated total: no remainder + To: nipcash.Pubkey("recipient"), + InterimIdentity: nipcash.Pubkey("myPubHex"), + InterimCredential: nipcash.BySecret("interim"), + }) + if err != nil { + t.Fatalf("transferFromSources: %v", err) + } + if result.Transfer.NewWalletToken != "interim-token" { + t.Fatalf("Transfer.NewWalletToken = %q, want the interim consolidate's own NewWalletToken (%q) surfaced through", result.Transfer.NewWalletToken, "interim-token") + } + if result.Transfer.NewWalletPubkey != "interim-pub" { + t.Fatalf("Transfer.NewWalletPubkey = %q, want %q", result.Transfer.NewWalletPubkey, "interim-pub") + } + if got := result.Transfer.RecipientToken("wallet1-original-token"); got != "interim-token" { + t.Fatalf("RecipientToken() = %q, want the interim wallet's token, not a consolidated-away source token", got) + } +} + +func TestTransferFromSources_InterimConsolidateLandsButTransferFails_PartialProgress(t *testing.T) { + transferErr := errors.New("transfer rejected") + fake := &fakeTransferConsolidater{ + consolidateFunc: func(params nipcash.CashConsolidateParams) (*nipcash.CashConsolidateResult, error) { + return &nipcash.CashConsolidateResult{AmountMillis: 8000, NewWalletPubkey: "interim"}, nil + }, + transferFunc: func(params nipcash.CashTransferParams) (*nipcash.CashTransferResult, error) { + return nil, transferErr + }, + } + _, err := transferFromSources(context.Background(), fake, TransferFromSourcesParams{ + Sources: []nipcash.Source{ + {WalletPubkey: "wallet1", Amount: 5000, Credential: nipcash.BySecret("s1")}, + {WalletPubkey: "wallet2", Amount: 3000, Credential: nipcash.BySecret("s2")}, + }, + Amount: 8000, + To: nipcash.Pubkey("recipient"), + InterimIdentity: nipcash.Pubkey("myPubHex"), + InterimCredential: nipcash.BySecret("interim"), + }) + var partial *PartialProgressError + if !errors.As(err, &partial) { + t.Fatalf("got %v, want *PartialProgressError", err) + } + if partial.Consolidated == nil { + t.Fatal("partial.Consolidated is nil — the interim consolidate's real result must be surfaced") + } + if partial.Transferred != nil { + t.Fatal("partial.Transferred must be nil — TransferFromSources' interim step is a consolidate, not a transfer") + } + if !errors.Is(err, transferErr) { + t.Fatal("the underlying transfer error must be unwrappable via errors.Is") + } +} From 76260bea2f26119c0b4e58a567ca8f0d43a05b4a Mon Sep 17 00:00:00 2001 From: naliyi <154817482+naliyi@users.noreply.github.com> Date: Wed, 16 Sep 2026 11:45:59 +0000 Subject: [PATCH 10/10] docs: add README example for bearer-mode cash mint/verify/rekey Walks through minting a bearer slice, handing over the combined "#" string, and the recipient's side: split it back apart, CheckClaim to confirm it's real before trusting it, then RekeyBearerSlice to move it out of shared custody under a fresh secret only the new holder knows. Co-Authored-By: Claude Sonnet 5 --- README.md | 83 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/README.md b/README.md index 3b85784..47a2990 100644 --- a/README.md +++ b/README.md @@ -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 "#" + // 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