From 2859b4a1fd6c4e701233d8b66e36301d4142959a Mon Sep 17 00:00:00 2001 From: wingha Date: Wed, 14 Oct 2026 17:07:44 +0000 Subject: [PATCH 1/3] nipcash/transport: canonicalise params strings as RFC 8785 does --- CHANGELOG.md | 4 + nipcash/transport/canonical_jcs_test.go | 65 +++++++++++++++ nipcash/transport/envelope.go | 101 ++++++++++++++++++++++-- 3 files changed, 165 insertions(+), 5 deletions(-) create mode 100644 nipcash/transport/canonical_jcs_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index e6e13fa..28ca9aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -134,6 +134,10 @@ privacy and performance fixes. ### Changed +- Changes the NIP-CASH params hash (`transport.CanonicalParamsHash`) to write + strings as RFC 8785 does, so `<`, `>`, `&`, U+2028 and U+2029 are no longer + escaped. Params containing them hash differently from earlier releases, so + clients and Hubs must upgrade together. - Moves `nipAZ` to NIP-AZ's new kinds: 9520 requests, 9521 receipts, 9523 on-behalf requests. Events of the old kinds 5520, 5521 and 5523 are no longer parsed, validated or indexed. ([#114](https://github.com/ohstr/nmilat/pull/114)) diff --git a/nipcash/transport/canonical_jcs_test.go b/nipcash/transport/canonical_jcs_test.go new file mode 100644 index 0000000..5ca3a1a --- /dev/null +++ b/nipcash/transport/canonical_jcs_test.go @@ -0,0 +1,65 @@ +package transport + +import ( + "encoding/json" + "testing" +) + +// NIP-CASH §Item Proofs: canonical params serialize strings as RFC 8785 does. +// The input spells every string escaped, and in a different key order, so only +// the canonicaliser decides the output. The expected hash was computed outside +// this package: printf of the expected bytes piped to sha256sum. +func TestCanonicalParams_RFC8785Strings(t *testing.T) { + in := json.RawMessage(`{ + "z": "<>&", + "q": "\"\\\b\t\n\f\r\u0001\u001f\u007f é", + "n": 12345678901234567890, + "arr": [1.50, true, null], + "a": "x
y
z" + }`) + want := `{"a":"x` + "
" + `y` + "
" + `z","arr":[1.50,true,null],"n":12345678901234567890,` + + `"q":"\"\\\b\t\n\f\r\u0001\u001f` + "\x7f" + ` é","z":"<>&"}` + const wantHash = "069de3ad539b89d0ab1afbbce26f1e49fc4afc0f5ddb4bb75342cdd22b823f7b" + + got, err := canonicalJSON(in) + if err != nil { + t.Fatal(err) + } + if string(got) != want { + t.Fatalf("canonical form:\n got %q\nwant %q", got, want) + } + hash, err := CanonicalParamsHash(in) + if err != nil { + t.Fatal(err) + } + if hash != wantHash { + t.Fatalf("ph = %s, want %s", hash, wantHash) + } +} + +// Each character the spec names on its own, so a regression points at one. +func TestCanonicalParams_CharactersWrittenLiterally(t *testing.T) { + for name, tc := range map[string]struct{ in, want string }{ + "less-than": {`{"s":"<"}`, `{"s":"<"}`}, + "greater": {`{"s":">"}`, `{"s":">"}`}, + "ampersand": {`{"s":"&"}`, `{"s":"&"}`}, + "U+2028": {`{"s":"
"}`, "{\"s\":\"
\"}"}, + "U+2029": {`{"s":"
"}`, "{\"s\":\"
\"}"}, + "non-ASCII": {`{"s":"é😀"}`, "{\"s\":\"é\U0001F600\"}"}, + "slash": {`{"s":"\/"}`, `{"s":"/"}`}, + "key escaped": {`{", &, U+2028 and U+2029, so its hash differs +// from any implementation following the spec. +func writeCanonical(buf *bytes.Buffer, v any) error { + switch v := v.(type) { + case nil: + buf.WriteString("null") + case bool: + if v { + buf.WriteString("true") + } else { + buf.WriteString("false") + } + case json.Number: + buf.WriteString(v.String()) + case string: + writeCanonicalString(buf, v) + case []any: + buf.WriteByte('[') + for i, elem := range v { + if i > 0 { + buf.WriteByte(',') + } + if err := writeCanonical(buf, elem); err != nil { + return err + } + } + buf.WriteByte(']') + case map[string]any: + keys := make([]string, 0, len(v)) + for k := range v { + keys = append(keys, k) + } + sort.Strings(keys) + buf.WriteByte('{') + for i, k := range keys { + if i > 0 { + buf.WriteByte(',') + } + writeCanonicalString(buf, k) + buf.WriteByte(':') + if err := writeCanonical(buf, v[k]); err != nil { + return err + } + } + buf.WriteByte('}') + default: + return fmt.Errorf("unexpected JSON value of type %T", v) + } + return nil +} + +// writeCanonicalString writes s as RFC 8785 does: only ", \ and control +// characters below U+0020 are escaped, as \b \t \n \f \r or else lowercase +// \u00XX; every other character is literal UTF-8. The decoder has already +// replaced invalid UTF-8, so s is valid. +func writeCanonicalString(buf *bytes.Buffer, s string) { + const hexDigits = "0123456789abcdef" + buf.WriteByte('"') + for i := 0; i < len(s); i++ { + c := s[i] + switch c { + case '"': + buf.WriteString(`\"`) + case '\\': + buf.WriteString(`\\`) + case '\b': + buf.WriteString(`\b`) + case '\t': + buf.WriteString(`\t`) + case '\n': + buf.WriteString(`\n`) + case '\f': + buf.WriteString(`\f`) + case '\r': + buf.WriteString(`\r`) + default: + if c < 0x20 { + buf.WriteString(`\u00`) + buf.WriteByte(hexDigits[c>>4]) + buf.WriteByte(hexDigits[c&0xf]) + } else { + buf.WriteByte(c) + } + } + } + buf.WriteByte('"') } // VerificationCost reports how many signature verifications this item will demand From 8e6e77a45a95000356a447d740f0c8e969c0d0a4 Mon Sep 17 00:00:00 2001 From: wingha Date: Wed, 14 Oct 2026 17:08:33 +0000 Subject: [PATCH 2/3] nipcw: carry the resolved max_amount and name the join refusal codes --- CHANGELOG.md | 3 +++ nipcw/create_circle_wallet.go | 36 +++++++++++++++++------------- nipcw/create_circle_wallet_test.go | 36 ++++++++++++++++++++++++++++++ nipcw/nipcw.go | 16 +++++++++++++ 4 files changed, 76 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 28ca9aa..36f86fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ privacy and performance fixes. ### Added +- Adds `MaxAmountMillis` to `nipcw.CreateCircleWalletResponse`, the spend cap + as the Hub resolved it, and the refusal codes `nipcw.ErrNotReady`, + `ErrRestricted`, `ErrWalletExists` and `ErrRateLimited`, as NIP-CW now specifies. - Adds `nipAZ.ValidateAltZapRequestForRelay`/`ValidateAltZapReceiptForRelay`, which `nipAZ/relayreg` now uses: relays keep receipts for real payments and reject only forged AltZap events, as NIP-AZ specifies. diff --git a/nipcw/create_circle_wallet.go b/nipcw/create_circle_wallet.go index 23f95a8..a9b3235 100644 --- a/nipcw/create_circle_wallet.go +++ b/nipcw/create_circle_wallet.go @@ -82,21 +82,26 @@ type createCircleWalletResponseWire struct { // circleWalletDetailsWire is the plaintext inside EncryptedDetails — the // joining member's own wallet identity and terms, readable only by them. type circleWalletDetailsWire struct { - PairingURI string `json:"pairing_uri"` - WalletPubkey string `json:"wallet_pubkey"` - ExpiresAt int64 `json:"expires_at"` - FeesPpm int `json:"fees_ppm"` - BudgetRenewal string `json:"budget_renewal"` + PairingURI string `json:"pairing_uri"` + WalletPubkey string `json:"wallet_pubkey"` + MaxAmount *uint64 `json:"max_amount,omitempty"` + ExpiresAt int64 `json:"expires_at"` + FeesPpm int `json:"fees_ppm"` + BudgetRenewal string `json:"budget_renewal"` } // CreateCircleWalletResponse is create_circle_wallet's response, with // PairingURI already decrypted — connect straight from it. type CreateCircleWalletResponse struct { - PairingURI string - WalletPubkey string - ExpiresAt int64 - FeesPpm int - BudgetRenewal string + PairingURI string + WalletPubkey string + // MaxAmountMillis is the spend cap as the Hub resolved it, which may differ + // from the requested one (NIP-CW §Response). Nil when the Hub did not say, + // so a missing cap never reads as a cap of zero. + MaxAmountMillis *uint64 + ExpiresAt int64 + FeesPpm int + BudgetRenewal string } // ParseResult parses create_circle_wallet's wire response, decrypting @@ -133,11 +138,12 @@ func (p CreateCircleWalletParams) ParseResult(hubPubkey string, data []byte) (*C return nil, fmt.Errorf("nipcw: parse wallet details: %w", err) } return &CreateCircleWalletResponse{ - PairingURI: details.PairingURI, - WalletPubkey: details.WalletPubkey, - ExpiresAt: details.ExpiresAt, - FeesPpm: details.FeesPpm, - BudgetRenewal: details.BudgetRenewal, + PairingURI: details.PairingURI, + WalletPubkey: details.WalletPubkey, + MaxAmountMillis: details.MaxAmount, + ExpiresAt: details.ExpiresAt, + FeesPpm: details.FeesPpm, + BudgetRenewal: details.BudgetRenewal, }, nil } diff --git a/nipcw/create_circle_wallet_test.go b/nipcw/create_circle_wallet_test.go index 067197b..cb8881d 100644 --- a/nipcw/create_circle_wallet_test.go +++ b/nipcw/create_circle_wallet_test.go @@ -114,6 +114,42 @@ func TestCreateCircleWalletParams_ParseResult_DecryptsDetails(t *testing.T) { if resp.WalletPubkey != "walletpubkey" || resp.ExpiresAt != 1234 || resp.BudgetRenewal != "never" { t.Fatalf("resp: %+v", resp) } + if resp.MaxAmountMillis != nil { + t.Fatalf("MaxAmountMillis = %d for a Hub that did not send max_amount, want nil", *resp.MaxAmountMillis) + } +} + +// NIP-CW §Response: max_amount is the cap as the Hub resolved it, which can differ +// from the request. Built from the wire text, so a dropped or renamed field shows. +func TestCreateCircleWalletParams_ParseResult_CarriesResolvedMaxAmount(t *testing.T) { + memberPrivHex, memberPubHex := generateTestKeypair(t) + hubPrivHex, hubPubHex := generateTestKeypair(t) + details := `{"pairing_uri":"` + testPairingURI + `","wallet_pubkey":"walletpubkey","max_amount":99000,` + + `"expires_at":1234,"fees_ppm":0,"budget_renewal":"monthly"}` + raw, err := json.Marshal(createCircleWalletResponseWire{EncryptedDetails: encryptForTest(t, hubPrivHex, memberPubHex, details)}) + if err != nil { + t.Fatal(err) + } + p := CreateCircleWalletParams{Credential: BySigning(memberPrivHex), MaxAmountMillis: 100000} + resp, err := p.ParseResult(hubPubHex, raw) + if err != nil { + t.Fatalf("ParseResult: %v", err) + } + if resp.MaxAmountMillis == nil || *resp.MaxAmountMillis != 99000 { + t.Fatalf("MaxAmountMillis = %v, want the Hub's resolved 99000", resp.MaxAmountMillis) + } +} + +// NIP-CW §Errors: the four refusal codes, spelled as the spec spells them. +func TestCreateCircleWalletRefusalCodes(t *testing.T) { + for got, want := range map[string]string{ + ErrNotReady: "NOT_READY", ErrRestricted: "RESTRICTED", + ErrWalletExists: "WALLET_EXISTS", ErrRateLimited: "RATE_LIMITED", + } { + if got != want { + t.Errorf("code %q, want %q", got, want) + } + } } // TestCreateCircleWalletParams_ParseResult_NoDetailsIsAnError pins that a diff --git a/nipcw/nipcw.go b/nipcw/nipcw.go index 309a816..4ecced3 100644 --- a/nipcw/nipcw.go +++ b/nipcw/nipcw.go @@ -12,6 +12,8 @@ // the full spec this package implements. package nipcw +import "github.com/ohstr/nmilat/nip47" + // KindCircleIdentityProof is NIP-CW's own per-call identity proof kind — // deliberately its own number, not NIP-CASH's 23198 (a structurally // similar but distinct NIP's proof), and not NIP-IC's 35521 (a long-lived, @@ -27,3 +29,17 @@ const KindCircleIdentityProof = 23199 // MethodCreateCircleWallet is the "method" field of a create_circle_wallet // NIP-47 request. const MethodCreateCircleWallet = "create_circle_wallet" + +// NIP-47 error codes a Hub MUST use to refuse create_circle_wallet (NIP-CW +// §Errors), exported so Hubs and clients share one spelling. +const ( + // ErrNotReady: the Hub cannot decide membership yet. Retry later. + ErrNotReady = "NOT_READY" + // ErrRestricted: the requester is not a member of this circle. + ErrRestricted = nip47.ErrRestricted + // ErrWalletExists: the requester already holds an active Circle Wallet + // under this Hub, until it is deleted. + ErrWalletExists = "WALLET_EXISTS" + // ErrRateLimited: the requester has asked too often. Retry later. + ErrRateLimited = nip47.ErrRateLimited +) From 243ad9056b36ea2840c30e2d5e663123c888b4ba Mon Sep 17 00:00:00 2001 From: wingha Date: Wed, 14 Oct 2026 17:21:23 +0000 Subject: [PATCH 3/3] docs(changelog): reference #123 --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 36f86fc..e8dce3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ privacy and performance fixes. - Adds `MaxAmountMillis` to `nipcw.CreateCircleWalletResponse`, the spend cap as the Hub resolved it, and the refusal codes `nipcw.ErrNotReady`, `ErrRestricted`, `ErrWalletExists` and `ErrRateLimited`, as NIP-CW now specifies. + ([#123](https://github.com/ohstr/nmilat/pull/123)) - Adds `nipAZ.ValidateAltZapRequestForRelay`/`ValidateAltZapReceiptForRelay`, which `nipAZ/relayreg` now uses: relays keep receipts for real payments and reject only forged AltZap events, as NIP-AZ specifies. @@ -141,6 +142,7 @@ privacy and performance fixes. strings as RFC 8785 does, so `<`, `>`, `&`, U+2028 and U+2029 are no longer escaped. Params containing them hash differently from earlier releases, so clients and Hubs must upgrade together. + ([#123](https://github.com/ohstr/nmilat/pull/123)) - Moves `nipAZ` to NIP-AZ's new kinds: 9520 requests, 9521 receipts, 9523 on-behalf requests. Events of the old kinds 5520, 5521 and 5523 are no longer parsed, validated or indexed. ([#114](https://github.com/ohstr/nmilat/pull/114))