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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,20 @@ and [Semantic Versioning](https://semver.org/).

### Fixed

- **Cashu wallet hardening from the gonuts bump** (pinned to the
integration ref of gonuts #23/#24/#25, re-pinned to the tagged release
once it exists): an empty-proofs token now fails the payment with a
normal error instead of a contained panic (gonuts #23); mint swap
rejections surface verbatim instead of empty `could not swap proofs:`
errors (gonuts #25), which also makes the `ErrTokenAlreadySpent`
sentinel actually reachable — this change broadens its match to the
phrasings mints really use ("Token already spent" / "inputs have
already been spent") and pins the whole chain with a two-layer test;
a token worth less than its keyset's input fee fails fast instead of
posting an absurd swap; and a second in-process wallet load returns a
clear "wallet database is locked" error instead of deadlocking
(gonuts #24).

- **nftables ruleset is packaged in full.** The OpenWrt recipe installed
`/etc/nftables.d/` one file at a time and named only
`20-nds-enforce.nft`, so `packaging/files/etc/nftables.d/30-backend-firewall.nft`
Expand Down
2 changes: 1 addition & 1 deletion src/cli/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ require (

require (
github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3 // indirect
github.com/OpenTollGate/gonuts-tollgate v0.11.1 // indirect
github.com/OpenTollGate/gonuts-tollgate v0.11.2-0.20260913230723-717c8863c8e5 // indirect
github.com/OpenTollGate/tollgate-module-basic-go/src/lightning v0.0.0-00010101000000-000000000000 // indirect
github.com/OpenTollGate/tollgate-module-basic-go/src/tollwallet v0.0.0 // indirect
github.com/OpenTollGate/tollgate-module-basic-go/src/utils v0.0.0 // indirect
Expand Down
4 changes: 2 additions & 2 deletions src/cli/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw=
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk=
github.com/OpenTollGate/gonuts-tollgate v0.11.1 h1:e7uf5VDwwT9pbhWVHTGWCxczyfaRb/V3QHuIq9cphiw=
github.com/OpenTollGate/gonuts-tollgate v0.11.1/go.mod h1:4er8keqQ4yg3Zuz0w1BuXclKSeKOk3SGqsEl4b0oI7M=
github.com/OpenTollGate/gonuts-tollgate v0.11.2-0.20260913230723-717c8863c8e5 h1:UvozCHUL1kYkhiGozgcT367Xw/L4aUZAGNo3sDZY0vw=
github.com/OpenTollGate/gonuts-tollgate v0.11.2-0.20260913230723-717c8863c8e5/go.mod h1:4er8keqQ4yg3Zuz0w1BuXclKSeKOk3SGqsEl4b0oI7M=
github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da h1:KjTM2ks9d14ZYCvmHS9iAKVt9AyzRSqNU1qabPih5BY=
github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da/go.mod h1:eHEWzANqSiWQsof+nXEI9bUVUyV6F53Fp89EuCh2EAA=
github.com/aead/siphash v1.0.1 h1:FwHfE/T45KPKYuuSAKyyvE+oPWcaQ+CUmFW0bPlM+kg=
Expand Down
2 changes: 1 addition & 1 deletion src/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ require (
github.com/FactomProject/basen v0.0.0-20150613233007-fe3947df716e // indirect
github.com/FactomProject/btcutilecc v0.0.0-20130527213604-d3a63a5752ec // indirect
github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3 // indirect
github.com/OpenTollGate/gonuts-tollgate v0.11.1 // indirect
github.com/OpenTollGate/gonuts-tollgate v0.11.2-0.20260913230723-717c8863c8e5 // indirect
github.com/OpenTollGate/tollgate-module-basic-go/src/lightning v0.0.0-00010101000000-000000000000 // indirect
github.com/OpenTollGate/tollgate-module-basic-go/src/tollgate_protocol v0.0.0 // indirect
github.com/OpenTollGate/tollgate-module-basic-go/src/tollwallet v0.0.0 // indirect
Expand Down
4 changes: 2 additions & 2 deletions src/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw=
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk=
github.com/OpenTollGate/gonuts-tollgate v0.11.1 h1:e7uf5VDwwT9pbhWVHTGWCxczyfaRb/V3QHuIq9cphiw=
github.com/OpenTollGate/gonuts-tollgate v0.11.1/go.mod h1:4er8keqQ4yg3Zuz0w1BuXclKSeKOk3SGqsEl4b0oI7M=
github.com/OpenTollGate/gonuts-tollgate v0.11.2-0.20260913230723-717c8863c8e5 h1:UvozCHUL1kYkhiGozgcT367Xw/L4aUZAGNo3sDZY0vw=
github.com/OpenTollGate/gonuts-tollgate v0.11.2-0.20260913230723-717c8863c8e5/go.mod h1:4er8keqQ4yg3Zuz0w1BuXclKSeKOk3SGqsEl4b0oI7M=
github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da h1:KjTM2ks9d14ZYCvmHS9iAKVt9AyzRSqNU1qabPih5BY=
github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da/go.mod h1:eHEWzANqSiWQsof+nXEI9bUVUyV6F53Fp89EuCh2EAA=
github.com/aead/siphash v1.0.1 h1:FwHfE/T45KPKYuuSAKyyvE+oPWcaQ+CUmFW0bPlM+kg=
Expand Down
2 changes: 1 addition & 1 deletion src/merchant/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ module github.com/OpenTollGate/tollgate-module-basic-go/src/merchant
go 1.25.0

require (
github.com/OpenTollGate/gonuts-tollgate v0.11.1
github.com/OpenTollGate/gonuts-tollgate v0.11.2-0.20260913230723-717c8863c8e5
github.com/OpenTollGate/tollgate-module-basic-go/src/config_manager v0.0.0
github.com/OpenTollGate/tollgate-module-basic-go/src/tollwallet v0.0.0
github.com/OpenTollGate/tollgate-module-basic-go/src/utils v0.0.0
Expand Down
4 changes: 2 additions & 2 deletions src/merchant/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw=
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk=
github.com/OpenTollGate/gonuts-tollgate v0.11.1 h1:e7uf5VDwwT9pbhWVHTGWCxczyfaRb/V3QHuIq9cphiw=
github.com/OpenTollGate/gonuts-tollgate v0.11.1/go.mod h1:4er8keqQ4yg3Zuz0w1BuXclKSeKOk3SGqsEl4b0oI7M=
github.com/OpenTollGate/gonuts-tollgate v0.11.2-0.20260913230723-717c8863c8e5 h1:UvozCHUL1kYkhiGozgcT367Xw/L4aUZAGNo3sDZY0vw=
github.com/OpenTollGate/gonuts-tollgate v0.11.2-0.20260913230723-717c8863c8e5/go.mod h1:4er8keqQ4yg3Zuz0w1BuXclKSeKOk3SGqsEl4b0oI7M=
github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da h1:KjTM2ks9d14ZYCvmHS9iAKVt9AyzRSqNU1qabPih5BY=
github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da/go.mod h1:eHEWzANqSiWQsof+nXEI9bUVUyV6F53Fp89EuCh2EAA=
github.com/aead/siphash v1.0.1 h1:FwHfE/T45KPKYuuSAKyyvE+oPWcaQ+CUmFW0bPlM+kg=
Expand Down
2 changes: 1 addition & 1 deletion src/tollwallet/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ module github.com/OpenTollGate/tollgate-module-basic-go/src/tollwallet
go 1.25.0

require (
github.com/OpenTollGate/gonuts-tollgate v0.11.1
github.com/OpenTollGate/gonuts-tollgate v0.11.2-0.20260913230723-717c8863c8e5
github.com/OpenTollGate/tollgate-module-basic-go/src/lightning v0.0.0-00010101000000-000000000000
github.com/stretchr/testify v1.10.0
)
Expand Down
4 changes: 2 additions & 2 deletions src/tollwallet/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw=
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk=
github.com/OpenTollGate/gonuts-tollgate v0.11.1 h1:e7uf5VDwwT9pbhWVHTGWCxczyfaRb/V3QHuIq9cphiw=
github.com/OpenTollGate/gonuts-tollgate v0.11.1/go.mod h1:4er8keqQ4yg3Zuz0w1BuXclKSeKOk3SGqsEl4b0oI7M=
github.com/OpenTollGate/gonuts-tollgate v0.11.2-0.20260913230723-717c8863c8e5 h1:UvozCHUL1kYkhiGozgcT367Xw/L4aUZAGNo3sDZY0vw=
github.com/OpenTollGate/gonuts-tollgate v0.11.2-0.20260913230723-717c8863c8e5/go.mod h1:4er8keqQ4yg3Zuz0w1BuXclKSeKOk3SGqsEl4b0oI7M=
github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da h1:KjTM2ks9d14ZYCvmHS9iAKVt9AyzRSqNU1qabPih5BY=
github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da/go.mod h1:eHEWzANqSiWQsof+nXEI9bUVUyV6F53Fp89EuCh2EAA=
github.com/aead/siphash v1.0.1 h1:FwHfE/T45KPKYuuSAKyyvE+oPWcaQ+CUmFW0bPlM+kg=
Expand Down
25 changes: 25 additions & 0 deletions src/tollwallet/sentinel_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,28 @@ func TestGetMintQuoteState_NilWallet_ReturnsError(t *testing.T) {
assert.ErrorIs(t, err, ErrWalletNotInitialized,
"GetMintQuoteState on an uninitialized wallet must return ErrWalletNotInitialized, not panic")
}

func TestIsAlreadySpentError_MintPhrasings(t *testing.T) {
cases := []struct {
name string
err error
want bool
}{
// Nutshell-family phrasing (the one the old code matched exactly).
{"nutshell", fmt.Errorf("Token already spent."), true},
// CDK-family phrasing, which the gonuts bump now surfaces verbatim.
{"cdk inputs", fmt.Errorf("swap rejected by mint (HTTP 400, code 3): inputs have already been spent"), true},
{"secrets", fmt.Errorf("secret already spent"), true},
{"case-insensitive", fmt.Errorf("INPUTS HAVE ALREADY BEEN SPENT"), true},
// The pre-bump swallowed rejection: empty message must NOT match,
// or every failed swap would become "already spent".
{"empty swallowed error", fmt.Errorf("could not swap proofs: "), false},
{"unrelated", fmt.Errorf("mint rate limited"), false},
{"nil", nil, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.want, isAlreadySpentError(tc.err))
})
}
}
22 changes: 20 additions & 2 deletions src/tollwallet/tollwallet.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,8 +142,12 @@ func (w *TollWallet) Receive(token cashu.Token) (uint64, error) {
// "token already spent", so we match on the error string at this
// boundary and wrap it with our own sentinel (ErrTokenAlreadySpent).
// Callers should use errors.Is(err, ErrTokenAlreadySpent) — no
// further string matching needed downstream.
if strings.Contains(err.Error(), "Token already spent") {
// further string matching needed downstream. The patterns cover the
// phrases mints actually use, and only work at all since the gonuts
// bump: older versions swallowed the mint's rejection entirely
// (empty "could not swap proofs: " errors), so this mapping could
// never fire.
if isAlreadySpentError(err) {
return 0, fmt.Errorf("%w: %v", ErrTokenAlreadySpent, err)
}
return 0, err
Expand Down Expand Up @@ -312,6 +316,20 @@ func MintURLMatches(a, b string) bool {
normalizePath(ua.Path) == normalizePath(ub.Path)
}

// isAlreadySpentError reports whether err is a mint rejection for reusing
// spent secrets. Mint families phrase it differently — Nutshell-style
// "Token already spent", CDK-style "inputs have already been spent" — and
// the match is case-insensitive on purpose. The empty errors older gonuts
// versions produced (swallowed rejections) deliberately do NOT match.
func isAlreadySpentError(err error) bool {
if err == nil {
return false
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "already been spent") ||
strings.Contains(msg, "already spent")
}

func hasLockedProofs(proofs cashu.Proofs) bool {
for _, proof := range proofs {
secret, err := nut10.DeserializeSecret(proof.Secret)
Expand Down
53 changes: 53 additions & 0 deletions src/tollwallet/tollwallet_receive_sentinel_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package tollwallet

import (
"errors"
"fmt"
"net/http"
"net/http/httptest"
"testing"

"github.com/OpenTollGate/gonuts-tollgate/cashu"
)

// TestReceive_SpentTokenMapsToSentinel is the two-layer pin for the gonuts
// bump: a mock mint rejects the swap with a CDK-style "inputs have already
// been spent"; gonuts (>= the bumped version) surfaces that rejection —
// older versions swallowed it into an empty error, which made the
// ErrTokenAlreadySpent mapping below unreachable. The test fails on either
// regression: swallowing at the gonuts layer, or match drift at this layer.
func TestReceive_SpentTokenMapsToSentinel(t *testing.T) {
const keysetID = "009a1f293253e41e"
const amount1Key = "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"

keysetsJSON := `{"keysets":[{"id":"` + keysetID + `","unit":"sat","active":true,` +
`"keys":{"1":"` + amount1Key + `"}}]}`
mint := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/keys", "/v1/keysets":
fmt.Fprint(w, keysetsJSON)
case "/v1/swap":
http.Error(w, `{"code":3,"detail":"inputs have already been spent"}`, http.StatusBadRequest)
default:
http.Error(w, "not found", http.StatusNotFound)
}
}))
defer mint.Close()

tw, err := New(t.TempDir(), []string{mint.URL}, false)
if err != nil {
t.Fatalf("New: %v", err)
}
defer tw.Shutdown()

proof := cashu.Proof{Amount: 1, Id: keysetID, Secret: "sentinel-e2e-secret", C: amount1Key}
token, err := cashu.NewTokenV3(cashu.Proofs{proof}, mint.URL, cashu.Sat, false)
if err != nil {
t.Fatal(err)
}

_, err = tw.Receive(token)
if !errors.Is(err, ErrTokenAlreadySpent) {
t.Fatalf("want ErrTokenAlreadySpent via errors.Is, got: %v", err)
}
}
2 changes: 1 addition & 1 deletion tests/cloud-lab/Dockerfile.mint
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ RUN apt-get update && \

# Build cdk-mintd with only the features we need
# --locked ensures reproducible builds from the published Cargo.lock
RUN cargo install cdk-mintd --no-default-features --features fakewallet,sqlite --locked
RUN cargo install cdk-mintd --version 0.17.6 --no-default-features --features fakewallet,sqlite --locked

# --- Runtime stage ---
FROM debian:bookworm-slim
Expand Down
4 changes: 2 additions & 2 deletions tests/cloud-lab/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,11 @@ docker compose ps
docker compose run --rm client

# Run a specific test file
docker compose run --rm client pytest -sv test_smoke_payment.py
docker compose run --rm client -sv test_smoke_payment.py

# Run the two-router tests (starts reseller too)
docker compose --profile two-router up -d
docker compose run --rm client pytest -sv test_two_router_autopay.py
docker compose run --rm client -sv test_two_router_autopay.py

# Teardown
docker compose down
Expand Down